From cc9656ed590e5a5131324410397fa4cc9adda132 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Sat, 2 May 2026 21:57:13 +0800 Subject: [PATCH] refactor(memory-diag): remove legacy aliases, centralize command metadata, prepare v1.5.4 - Remove legacy CLI aliases (health, quality, rejections, disappearances, trace) - Centralize command metadata in command-metadata.ts - Move trace lifecycle into explain command - Move disappearance helpers into missing formatter - Remove cleanup:workspaces from package scripts (dev tool preserved) - Bump version to 1.5.4 --- CHANGELOG.md | 13 ++ RELEASE_NOTES.md | 21 ++ package.json | 3 +- scripts/memory-diag/cli.ts | 81 ++----- scripts/memory-diag/command-metadata.ts | 16 ++ scripts/memory-diag/command-registry.ts | 11 - .../memory-diag/commands/disappearances.ts | 14 -- scripts/memory-diag/commands/explain.ts | 12 +- scripts/memory-diag/commands/health.ts | 59 ----- scripts/memory-diag/commands/quality.ts | 14 -- scripts/memory-diag/commands/rejections.ts | 19 -- scripts/memory-diag/commands/status.ts | 3 - scripts/memory-diag/commands/trace.ts | 16 -- .../memory-diag/formatters/disappearances.ts | 45 ---- scripts/memory-diag/formatters/health.ts | 193 ---------------- scripts/memory-diag/formatters/missing.ts | 46 +++- scripts/memory-diag/formatters/quality.ts | 119 ---------- scripts/memory-diag/formatters/rejections.ts | 98 -------- scripts/memory-diag/types.ts | 8 +- tests/memory-diag-cli.test.ts | 111 +++------ tests/memory-diag-formatters.test.ts | 59 ++--- tests/memory-diag.test.ts | 216 +++++------------- 22 files changed, 240 insertions(+), 937 deletions(-) create mode 100644 scripts/memory-diag/command-metadata.ts delete mode 100644 scripts/memory-diag/commands/disappearances.ts delete mode 100644 scripts/memory-diag/commands/health.ts delete mode 100644 scripts/memory-diag/commands/quality.ts delete mode 100644 scripts/memory-diag/commands/rejections.ts delete mode 100644 scripts/memory-diag/commands/trace.ts delete mode 100644 scripts/memory-diag/formatters/disappearances.ts delete mode 100644 scripts/memory-diag/formatters/health.ts delete mode 100644 scripts/memory-diag/formatters/quality.ts delete mode 100644 scripts/memory-diag/formatters/rejections.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ff47891..f25126f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.5.4] - 2026-05-02 + +### Changed + +- Centralized the supported `memory-diag` command metadata for the official public commands: `status`, `rejected`, `missing`, and `explain`. +- Removed pre-public legacy aliases so unsupported spellings now use the standard unknown-subcommand path instead of carrying v2.0 compatibility debt. +- Kept `coverage` and `audit` as hidden maintainer diagnostics with neutral maintainer-only notices. +- Removed public npm script exposure for the internal workspace cleanup tool while keeping the development tool in the repository. + +### Fixed + +- Moved missing-memory disappearance detail formatting into the official `missing` formatter before deleting legacy disappearance formatter code. + ## [1.5.3] - 2026-05-02 ### Added diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6134353..5ab7166 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,26 @@ # Release Notes +## 1.5.4 (2026-05-02) + +### Memory Diagnostics Surface Cleanup + +This cleanup release keeps the newly published `memory-diag` CLI small before legacy spellings become compatibility debt. + +### What Changed + +- **Official commands only**: the public CLI surface is `status`, `rejected`, `missing`, and `explain`. +- **Pre-public aliases removed**: old spellings such as `health`, `quality`, `rejections`, `disappearances`, and `trace` are no longer recognized. +- **Maintainer diagnostics clarified**: hidden `coverage` and `audit` commands remain available as maintainer-only diagnostics and stay out of public usage output. +- **Cleaner internals**: current command metadata now has one source of truth, and legacy command/formatter wrappers were removed. +- **Internal cleanup script hidden**: the workspace cleanup helper is no longer exposed as an npm script; the underlying development tool remains in `scripts/dev/`. + +### Validation + +- `npm run typecheck` — `TYPECHECK_PASS` +- `npm test` — 347 tests passing, `TEST_PASS` + +--- + ## 1.5.3 (2026-05-02) ### Published Memory Diagnostics CLI diff --git a/package.json b/package.json index 351cf5d..fa24079 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-working-memory", - "version": "1.5.3", + "version": "1.5.4", "description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state", "type": "module", "main": "index.ts", @@ -24,7 +24,6 @@ "diag": "node --experimental-strip-types scripts/memory-diag.ts", "typecheck": "tsc --noEmit && node -e \"console.log('TYPECHECK_PASS')\"", "test": "node --import ./tests/setup-xdg-data-home.ts --test --experimental-strip-types tests/*.test.ts && node -e \"console.log('TEST_PASS')\"", - "cleanup:workspaces": "node --experimental-strip-types scripts/dev/cleanup-workspaces.ts", "check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test" }, "keywords": [ diff --git a/scripts/memory-diag/cli.ts b/scripts/memory-diag/cli.ts index 5772b48..dbe79f2 100644 --- a/scripts/memory-diag/cli.ts +++ b/scripts/memory-diag/cli.ts @@ -1,4 +1,5 @@ -import type { CliOptions, Command, LegacyCommand, ParsedArgs } from "./types.ts"; +import { HIDDEN_COMMAND_NOTICES, isCommand } from "./command-metadata.ts"; +import type { CliOptions, Command, ParsedArgs } from "./types.ts"; export type { ParsedArgs } from "./types.ts"; @@ -21,23 +22,6 @@ function error(message: string): ParsedArgs { return { ok: false, message, usage: usage(), exitCode: 1 }; } -function isCommand(value: string | undefined): value is Command { - return value === "status" - || value === "rejected" - || value === "missing" - || value === "explain" - || value === "coverage" - || value === "audit"; -} - -function isLegacyCommand(value: string | undefined): value is LegacyCommand { - return value === "health" - || value === "quality" - || value === "rejections" - || value === "disappearances" - || value === "trace"; -} - function isValidSince(rawSince: string): boolean { if (/^(\d+)([dhm])$/i.test(rawSince)) return true; return !Number.isNaN(new Date(rawSince).getTime()); @@ -50,44 +34,19 @@ export function parseArgs(argv: string[]): ParsedArgs { } let command: Command = "status"; - let legacyCommand: LegacyCommand | undefined; let deprecationNotice: string | undefined; let rest = argv; if (first && !first.startsWith("--")) { rest = tail; if (isCommand(first)) { command = first; - if (first === "coverage") { - deprecationNotice = "Note: 'coverage' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit evidence'."; - } else if (first === "audit") { - deprecationNotice = "Note: 'audit' is now a maintainer-only diagnostic. This alias will be removed in v2.0 unless replaced by 'audit migrations'."; - } - } else if (isLegacyCommand(first)) { - legacyCommand = first; - if (first === "health") { - command = "status"; - deprecationNotice = "Note: 'health' is now 'status'. This alias will be removed in v2.0."; - } else if (first === "quality") { - command = "status"; - deprecationNotice = "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0."; - } else if (first === "rejections") { - command = "rejected"; - deprecationNotice = "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0."; - } else if (first === "disappearances") { - command = "missing"; - deprecationNotice = "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0."; - } else { - command = "explain"; - deprecationNotice = "Note: 'trace --memory ' is now 'explain '. This alias will be removed in v2.0."; - } + deprecationNotice = HIDDEN_COMMAND_NOTICES[first]; } else { return error(`Unknown subcommand: ${first}`); } } const options: CliOptions = { raw: false, positional: [] }; - if (legacyCommand) options.legacyCommand = legacyCommand; - if (legacyCommand === "quality") options.verbose = true; for (let i = 0; i < rest.length; i += 1) { const arg = rest[i]; if (arg === "--raw") options.raw = true; @@ -98,8 +57,6 @@ export function parseArgs(argv: string[]): ParsedArgs { else if (arg === "--soft-only") options.softOnly = true; else if (arg === "--trigger-only") options.triggerOnly = true; else if (arg === "--include-historical") options.includeHistorical = true; - else if (arg === "--quality") options.quality = true; - else if (arg === "--unique") options.unique = true; else if (arg === "--explain") options.explain = true; else if (arg === "--workspace") { const value = rest[++i]; @@ -121,14 +78,13 @@ export function parseArgs(argv: string[]): ParsedArgs { const value = rest[++i]; if (!value) return error("--memory requires an id"); options.memory = value; - } else if (!arg.startsWith("--") && command === "explain" && legacyCommand !== "trace") { + } else if (!arg.startsWith("--") && command === "explain") { options.positional?.push(arg); } else { return error(`Unknown option: ${arg}`); } } - const displayCommand = legacyCommand ?? command; if (command === "explain") { const positional = options.positional ?? []; if (positional.length > 1) return error("explain accepts at most one memory id"); @@ -138,34 +94,27 @@ export function parseArgs(argv: string[]): ParsedArgs { return error(`Unknown option: ${options.positional?.[0]}`); } - if (legacyCommand === "health") { - if (options.all && options.workspace) return error("Use either --all or --workspace, not both"); - if (options.json && options.all) return error("health --json does not support --all"); - } else if (command === "status") { - if (options.all) return error(`${displayCommand} does not accept --all`); + if (command === "status") { + if (options.all) return error(`${command} does not accept --all`); } else if (command === "rejected" || command === "missing" || command === "coverage" || command === "explain") { - if (options.all) return error(`${displayCommand} does not accept --all`); + if (options.all) return error(`${command} does not accept --all`); } else { - if (options.all || options.workspace) return error(`${displayCommand} does not accept --all or --workspace`); + if (options.all || options.workspace) return error(`${command} does not accept --all or --workspace`); } if (options.json && command !== "status" && command !== "rejected" && command !== "missing" && command !== "coverage") { - return error(`${displayCommand} does not accept --json`); + return error(`${command} does not accept --json`); } - if (legacyCommand === "rejections" && options.json && !options.quality) return error("rejections --json requires --quality"); if (command !== "rejected" && (options.softOnly || options.triggerOnly || options.since)) { - return error(`${displayCommand} does not accept rejection filters`); + return error(`${command} does not accept rejection filters`); } - if (command !== "coverage" && options.includeHistorical) return error(`${displayCommand} does not accept --include-historical`); - if (command !== "rejected" && (options.quality || options.reason || options.unique)) return error(`${displayCommand} does not accept rejection quality filters`); - if (command !== "missing" && options.explain) return error(`${displayCommand} does not accept --explain`); + if (command !== "coverage" && options.includeHistorical) return error(`${command} does not accept --include-historical`); + if (command !== "rejected" && options.reason) return error(`${command} does not accept rejection filters`); + if (command !== "missing" && options.explain) return error(`${command} does not accept --explain`); if (command !== "audit" && options.migration) { - return error(`${displayCommand} does not accept --migration`); - } - if (legacyCommand === "trace" && !options.memory) { - return error("--memory requires an id"); + return error(`${command} does not accept --migration`); } if (command !== "explain" && options.memory) { - return error(`${displayCommand} does not accept --memory`); + return error(`${command} does not accept --memory`); } if (command === "rejected" && options.since && !isValidSince(options.since)) { return error(`Invalid --since value: ${options.since}`); diff --git a/scripts/memory-diag/command-metadata.ts b/scripts/memory-diag/command-metadata.ts new file mode 100644 index 0000000..33a4234 --- /dev/null +++ b/scripts/memory-diag/command-metadata.ts @@ -0,0 +1,16 @@ +export const VISIBLE_COMMANDS = ["status", "rejected", "missing", "explain"] as const; +export const HIDDEN_COMMANDS = ["coverage", "audit"] as const; + +export type VisibleCommand = typeof VISIBLE_COMMANDS[number]; +export type HiddenCommand = typeof HIDDEN_COMMANDS[number]; +export type Command = VisibleCommand | HiddenCommand; + +export const HIDDEN_COMMAND_NOTICES: Partial> = { + coverage: "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface.", + audit: "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface.", +}; + +export function isCommand(value: string | undefined): value is Command { + return value !== undefined + && ([...VISIBLE_COMMANDS, ...HIDDEN_COMMANDS] as readonly string[]).includes(value); +} diff --git a/scripts/memory-diag/command-registry.ts b/scripts/memory-diag/command-registry.ts index 4c95ff6..d7f9888 100644 --- a/scripts/memory-diag/command-registry.ts +++ b/scripts/memory-diag/command-registry.ts @@ -1,23 +1,12 @@ import { runAudit } from "./commands/audit.ts"; import { runCoverage } from "./commands/coverage.ts"; -import { runDisappearances } from "./commands/disappearances.ts"; import { runExplain } from "./commands/explain.ts"; -import { runHealth } from "./commands/health.ts"; import { runMissing } from "./commands/missing.ts"; -import { runQuality } from "./commands/quality.ts"; import { runRejected } from "./commands/rejected.ts"; -import { runRejections } from "./commands/rejections.ts"; import { runStatus } from "./commands/status.ts"; -import { runTrace } from "./commands/trace.ts"; import type { CliOptions, Command, CommandResult } from "./types.ts"; export async function dispatch(command: Command, options: CliOptions): Promise { - if (options.legacyCommand === "health") return runHealth(options); - if (options.legacyCommand === "quality") return runQuality(options); - if (options.legacyCommand === "rejections") return runRejections(options); - if (options.legacyCommand === "disappearances") return runDisappearances(options); - if (options.legacyCommand === "trace") return runTrace(options); - switch (command) { case "status": return runStatus(options); case "rejected": return runRejected(options); diff --git a/scripts/memory-diag/commands/disappearances.ts b/scripts/memory-diag/commands/disappearances.ts deleted file mode 100644 index de567bd..0000000 --- a/scripts/memory-diag/commands/disappearances.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { buildInspectionReadModel, disappearanceRows } from "../inspection-model.ts"; -import { buildDisappearancesJSON, formatDisappearances } from "../formatters/disappearances.ts"; -import type { CliOptions, CommandResult } from "../types.ts"; - -export async function runDisappearances(options: CliOptions): Promise { - const model = await buildInspectionReadModel(options); - const rows = disappearanceRows(model); - - if (options.json) { - return { stdout: JSON.stringify(buildDisappearancesJSON(rows, { explain: options.explain }), null, 2) }; - } - - return { stdout: formatDisappearances(rows, { explain: options.explain }) }; -} diff --git a/scripts/memory-diag/commands/explain.ts b/scripts/memory-diag/commands/explain.ts index b7107a8..4a5f017 100644 --- a/scripts/memory-diag/commands/explain.ts +++ b/scripts/memory-diag/commands/explain.ts @@ -1,10 +1,18 @@ +import { traceMemoryLifecycle } from "../../../src/evidence-log.ts"; import { formatExplain } from "../formatters/explain.ts"; +import { formatTrace } from "../formatters/trace.ts"; import { snapshotForOptions } from "../workspace-snapshot.ts"; import type { CliOptions, CommandResult } from "../types.ts"; -import { runTrace } from "./trace.ts"; export async function runExplain(options: CliOptions): Promise { - if (options.memory) return runTrace(options); + if (options.memory) { + const root = options.workspace ?? process.cwd(); + const [snapshot, trace] = await Promise.all([ + snapshotForOptions(options), + traceMemoryLifecycle(root, { memoryId: options.memory }), + ]); + return { stdout: formatTrace(options.memory, snapshot, trace) }; + } const snapshot = await snapshotForOptions(options); return { stdout: formatExplain(snapshot) }; diff --git a/scripts/memory-diag/commands/health.ts b/scripts/memory-diag/commands/health.ts deleted file mode 100644 index 4cda542..0000000 --- a/scripts/memory-diag/commands/health.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { join } from "node:path"; -import { workspaceKey, workspaceMemoryPath, workspacePendingJournalPath } from "../../../src/paths.ts"; -import { scanWorkspaceResidues } from "../../../src/workspace-cleanup.ts"; -import type { PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts"; -import { formatWorkspaceHealth, type WorkspaceHealthInput } from "../formatters/health.ts"; -import { pathExists, readJSONFile } from "../io.ts"; -import { buildMemoryDiagJSON } from "../workspace-snapshot.ts"; -import type { CliOptions, CommandResult } from "../types.ts"; - -type WorkspaceHealthCommandInput = Omit; - -async function workspaceHealthOutput(input: WorkspaceHealthCommandInput): Promise { - return formatWorkspaceHealth({ ...input, now: Date.now() }, { - rawStore: await readJSONFile(input.memoryPath), - rawJournal: await readJSONFile(input.pendingPath), - pendingExists: pathExists(input.pendingPath), - }); -} - -export async function runHealth(options: CliOptions): Promise { - if (options.json) { - const root = options.workspace ?? process.cwd(); - return { stdout: JSON.stringify(await buildMemoryDiagJSON(root), null, 2) }; - } - - if (options.all) { - const scan = await scanWorkspaceResidues({ includeOrphans: true, minAgeMs: 0 }); - const lines: string[] = ["Workspace memory health", ""]; - if (scan.results.length === 0) { - lines.push("No workspace stores found."); - return { stdout: lines.join("\n") }; - } - for (let i = 0; i < scan.results.length; i += 1) { - const result = scan.results[i]; - if (i > 0) lines.push(""); - lines.push(await workspaceHealthOutput({ - root: result.root, - key: result.workspaceKey, - memoryPath: join(result.workspaceDir, "workspace-memory.json"), - pendingPath: join(result.workspaceDir, "workspace-pending-journal.json"), - raw: options.raw, - })); - } - return { stdout: lines.join("\n") }; - } - - const root = options.workspace ?? process.cwd(); - const key = await workspaceKey(root); - return { - stdout: await workspaceHealthOutput({ - root, - key, - memoryPath: await workspaceMemoryPath(root), - pendingPath: await workspacePendingJournalPath(root), - raw: options.raw, - includeTitle: true, - }), - }; -} diff --git a/scripts/memory-diag/commands/quality.ts b/scripts/memory-diag/commands/quality.ts deleted file mode 100644 index 13bd0fd..0000000 --- a/scripts/memory-diag/commands/quality.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { buildInspectionReadModel } from "../inspection-model.ts"; -import { buildQualityJSON, formatQuality } from "../formatters/quality.ts"; -import type { CliOptions, CommandResult } from "../types.ts"; - -export async function runQuality(options: CliOptions): Promise { - const model = await buildInspectionReadModel(options); - const now = Date.now(); - - if (options.json) { - return { stdout: JSON.stringify(buildQualityJSON(model, new Date(now).toISOString(), now), null, 2) }; - } - - return { stdout: formatQuality(model, now) }; -} diff --git a/scripts/memory-diag/commands/rejections.ts b/scripts/memory-diag/commands/rejections.ts deleted file mode 100644 index 129c4a6..0000000 --- a/scripts/memory-diag/commands/rejections.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { formatRejections, formatRejectionQuality, buildRejectionQualityJSON } from "../formatters/rejections.ts"; -import { loadRejectionRecords, rejectionFalsePositiveRisk, rejectionQualitySummary, uniqueByCanonicalText } from "../rejections-model.ts"; -import type { CliOptions, CommandResult } from "../types.ts"; - -export async function runRejections(options: CliOptions): Promise { - const { path, invalidLines, records } = await loadRejectionRecords(options); - const normalized = options.unique ? uniqueByCanonicalText(records) : records; - - if (options.quality) { - const summary = rejectionQualitySummary(records); - if (options.json) { - return { stdout: JSON.stringify({ ...buildRejectionQualityJSON(summary), falsePositiveRisk: rejectionFalsePositiveRisk(summary) }, null, 2) }; - } - - return { stdout: formatRejectionQuality({ path, invalidLines, summary, raw: options.raw }) }; - } - - return { stdout: formatRejections({ path, invalidLines, records: normalized, raw: options.raw }) }; -} diff --git a/scripts/memory-diag/commands/status.ts b/scripts/memory-diag/commands/status.ts index 52c7e29..9964ee0 100644 --- a/scripts/memory-diag/commands/status.ts +++ b/scripts/memory-diag/commands/status.ts @@ -6,7 +6,6 @@ import { retentionCandidatesForDiag, retentionClockSummary, daysSinceIso } from import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts"; import { isWithinDays, memoryDiagJSONFromSnapshot } from "../workspace-snapshot.ts"; import type { CliOptions, CommandResult, MemoryInspectionReadModel } from "../types.ts"; -import { runHealth } from "./health.ts"; export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date.now()): StatusReadout { const active = model.store.entries.filter(entry => entry.status !== "superseded"); @@ -81,8 +80,6 @@ export function buildStatusReadout(model: MemoryInspectionReadModel, now = Date. } export async function runStatus(options: CliOptions): Promise { - if (options.legacyCommand === "health" && options.all) return runHealth(options); - const now = Date.now(); const model = await buildInspectionReadModel(options); const readout = buildStatusReadout(model, now); diff --git a/scripts/memory-diag/commands/trace.ts b/scripts/memory-diag/commands/trace.ts deleted file mode 100644 index c22939d..0000000 --- a/scripts/memory-diag/commands/trace.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { traceMemoryLifecycle } from "../../../src/evidence-log.ts"; -import { formatTrace } from "../formatters/trace.ts"; -import { snapshotForOptions } from "../workspace-snapshot.ts"; -import type { CliOptions, CommandResult } from "../types.ts"; - -export async function runTrace(options: CliOptions): Promise { - const root = options.workspace ?? process.cwd(); - const memoryId = options.memory; - if (!memoryId) return { stdout: "", stderr: "--memory requires an id", exitCode: 1 }; - - const [snapshot, trace] = await Promise.all([ - snapshotForOptions(options), - traceMemoryLifecycle(root, { memoryId }), - ]); - return { stdout: formatTrace(memoryId, snapshot, trace) }; -} diff --git a/scripts/memory-diag/formatters/disappearances.ts b/scripts/memory-diag/formatters/disappearances.ts deleted file mode 100644 index b0fc763..0000000 --- a/scripts/memory-diag/formatters/disappearances.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { disappearanceRows } from "../inspection-model.ts"; -import { eventCounts } from "../inspection-model.ts"; -import { formatDetails } from "../text.ts"; - -export type DisappearanceRows = ReturnType; - -export function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record { - return { - version: 1, - generatedAt: options.generatedAt ?? new Date().toISOString(), - disappearances: rows.map(row => ({ - id: row.id, - classification: row.classification, - terminalType: row.terminalType, - reasonCodes: row.reasonCodes, - eventCounts: eventCounts(row.events), - details: options.explain ? row.event?.details : undefined, - })), - }; -} - -export function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string { - const lines: string[] = []; - lines.push("Memory disappearances"); - lines.push(""); - if (rows.length === 0) { - lines.push("No evidence-only memories found."); - return lines.join("\n"); - } - for (const row of rows) { - const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none"; - lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`); - if (options.explain) { - lines.push(` events: ${row.events.map(event => event.type).join(", ")}`); - if (row.event?.type === "memory_removed_capacity") { - lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`); - } - const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap")); - if (renderTypeCap) { - lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`); - } - } - } - return lines.join("\n"); -} diff --git a/scripts/memory-diag/formatters/health.ts b/scripts/memory-diag/formatters/health.ts deleted file mode 100644 index a2fe092..0000000 --- a/scripts/memory-diag/formatters/health.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { assessMemoryQuality } from "../../../src/memory-quality.ts"; -import { - DORMANT_DECAY_MULTIPLIER, - RETENTION_TYPE_MAX, - WORKSPACE_DORMANT_AFTER_DAYS, -} from "../../../src/retention.ts"; -import { renderWorkspaceMemory } from "../../../src/workspace-memory.ts"; -import type { LongTermSource, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../../src/types.ts"; -import { LONG_TERM_LIMITS } from "../../../src/types.ts"; -import { SUSPICIOUS_REASONS, TYPES } from "../constants.ts"; -import { - ageDays, - daysSinceIso, - formatStrength, - isSafetyCriticalForDiag, - promotionLimit, - retentionCandidatesForDiag, -} from "../retention-model.ts"; -import { - canonicalMemoryText, - cleanPath, - cleanText, - countBy, - formatPercent, - formatWorkspaceIdentity, - truncate, -} from "../text.ts"; -import { normalizedJournal, normalizedStore } from "../workspace-snapshot.ts"; - -export type WorkspaceHealthInput = { - root?: string; - key: string; - memoryPath: string; - pendingPath: string; - raw: boolean; - now: number; - includeTitle?: boolean; -}; - -export type WorkspaceHealthLoadedData = { - rawStore: WorkspaceMemoryStore | null; - rawJournal: PendingMemoryJournalStore | null; - pendingExists: boolean; -}; - -export function formatWorkspaceHealth(input: WorkspaceHealthInput, loadedData: WorkspaceHealthLoadedData): string { - const lines: string[] = []; - if (input.includeTitle) { - lines.push("Workspace memory health"); - lines.push(""); - } - - const rawStore = loadedData.rawStore; - const storeRoot = rawStore?.workspace?.root ?? input.root ?? ""; - const storeKey = rawStore?.workspace?.key ?? input.key; - const store = normalizedStore(rawStore, storeRoot, storeKey); - const journal = normalizedJournal(loadedData.rawJournal); - - const identity = formatWorkspaceIdentity(storeKey, storeRoot || undefined, input.raw); - if (identity) lines.push(identity); - lines.push(`memoryPath=${cleanPath(input.memoryPath, input.raw)}`); - lines.push(`pendingPath=${cleanPath(input.pendingPath, input.raw)}`); - if (!rawStore) lines.push("memory store: missing or unreadable (treated as empty)"); - if (!loadedData.pendingExists) lines.push("pending journal: missing (treated as empty)"); - lines.push(""); - - const active = store.entries.filter(entry => entry.status !== "superseded"); - const superseded = store.entries.filter(entry => entry.status === "superseded"); - const retention = retentionCandidatesForDiag(store, input.now); - const renderedEntries = retention.rendered.map(item => item.entry); - const renderedEstimate = renderWorkspaceMemory(store).length; - - lines.push(`Stored active memories: ${active.length}`); - lines.push(`Superseded memories: ${superseded.length}`); - lines.push(`Rendered candidates: ${renderedEntries.length}`); - lines.push(`Rendered estimate: ${renderedEstimate.toLocaleString()} chars`); - lines.push(""); - - const pendingEntries = journal.entries; - const retryable = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) < promotionLimit(entry.source)).length; - const nearRetryLimit = pendingEntries.filter(entry => (entry.promotionAttempts ?? 0) >= promotionLimit(entry.source) - 1).length; - const pendingBySource = countBy(pendingEntries.map(entry => entry.source)); - lines.push("Pending journal:"); - lines.push(` total: ${pendingEntries.length}`); - lines.push(` retryable: ${retryable}`); - lines.push(` near retry limit: ${nearRetryLimit}`); - lines.push(" by source:"); - for (const source of ["explicit", "manual", "compaction"] as LongTermSource[]) { - lines.push(` ${source}: ${pendingBySource.get(source) ?? 0}`); - } - lines.push(""); - - lines.push("By type:"); - for (const type of TYPES) { - const storedCount = active.filter(entry => entry.type === type).length; - const renderedCount = renderedEntries.filter(entry => entry.type === type).length; - const supersededCount = superseded.filter(entry => entry.type === type).length; - lines.push(` ${type.padEnd(9)} stored=${String(storedCount).padEnd(3)} rendered=${String(renderedCount).padEnd(3)} typeCap=${RETENTION_TYPE_MAX[type]} superseded=${supersededCount}`); - } - lines.push(""); - - lines.push("Retention caps:"); - lines.push(` type-capped entries: ${retention.typeCapped.length}`); - lines.push(` global-cap overflow: ${retention.globalCapped.length}`); - lines.push(""); - - const olderThan30 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 30).length; - const olderThan90 = active.filter(entry => (ageDays(entry, input.now) ?? 0) > 90).length; - const staleMarked = active.filter(entry => { - const days = ageDays(entry, input.now); - return Boolean(entry.staleAfterDays && days !== null && days > entry.staleAfterDays); - }).length; - lines.push("Age:"); - lines.push(` stale-marked: ${staleMarked}`); - lines.push(` older than 30d: ${olderThan30}`); - lines.push(` older than 90d: ${olderThan90}`); - lines.push(""); - - const wallDaysSinceActivity = daysSinceIso(store.lastActivityAt, input.now); - const dormantDiscountActive = wallDaysSinceActivity !== null && wallDaysSinceActivity > WORKSPACE_DORMANT_AFTER_DAYS; - const dormantDaysPastGrace = wallDaysSinceActivity === null - ? 0 - : Math.max(0, wallDaysSinceActivity - WORKSPACE_DORMANT_AFTER_DAYS); - lines.push("Dormancy:"); - lines.push(` lastActivityAt: ${store.lastActivityAt ?? "(missing)"}`); - lines.push(` wall days since activity: ${wallDaysSinceActivity === null ? "unknown" : wallDaysSinceActivity.toFixed(1)}`); - lines.push(` dormant discount active: ${dormantDiscountActive ? "yes" : "no"}`); - lines.push(` dormant days past grace: ${dormantDaysPastGrace.toFixed(1)}`); - lines.push(` dormant multiplier: ${DORMANT_DECAY_MULTIPLIER}`); - lines.push(""); - - const highImportanceCount = active.filter(entry => entry.userImportance === "high").length; - const safetyCriticalCount = active.filter(isSafetyCriticalForDiag).length; - const maxReinforcedCount = active.filter(entry => (entry.reinforcementCount ?? 0) >= 6).length; - const highImportanceRatio = active.length === 0 ? 0 : highImportanceCount / active.length; - const maxReinforcedRatio = active.length === 0 ? 0 : maxReinforcedCount / active.length; - const highImportanceAlert = highImportanceRatio > 0.3; - const safetyCriticalWarning = safetyCriticalCount > 0; - const maxReinforcedAlert = maxReinforcedRatio > 0.1; - lines.push("Retention monitoring:"); - lines.push(` high_importance_ratio: ${formatPercent(highImportanceRatio)} (alert > 30%)${highImportanceAlert ? " ALERT" : ""}`); - lines.push(` safety_critical_count: ${safetyCriticalCount} (deprecated field)${safetyCriticalWarning ? " WARNING" : ""}`); - lines.push(` max_reinforced_count: ${maxReinforcedAlert ? `${maxReinforcedCount} (${formatPercent(maxReinforcedRatio)}, alert > 10%) ALERT` : `${maxReinforcedCount} (alert > 10% active)`}`); - lines.push(""); - - const qualityByEntry = active.map(entry => ({ entry, quality: assessMemoryQuality(entry) })); - const duplicateCounts = countBy(active.map(entry => `${entry.type}:${canonicalMemoryText(entry.text)}`)); - const duplicateExtras = [...duplicateCounts.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0); - lines.push("Quality warnings:"); - lines.push(` progress-like active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("progress_snapshot")).length}`); - lines.push(` path-heavy active memories: ${qualityByEntry.filter(item => item.quality.reasons.includes("path_heavy")).length}`); - lines.push(` duplicate-ish exact canonical text: ${duplicateExtras}`); - lines.push(` very long entries: ${active.filter(entry => entry.text.length > LONG_TERM_LIMITS.maxEntryTextChars).length}`); - lines.push(""); - - lines.push("Suspicious active memories:"); - for (const reason of SUSPICIOUS_REASONS) { - lines.push(` ${reason}-like: ${qualityByEntry.filter(item => item.quality.reasons.includes(reason)).length}`); - } - - const failingQuality = qualityByEntry.filter(item => !item.quality.accepted); - if (failingQuality.length > 0) { - lines.push(""); - lines.push("Active memories failing offline quality checks:"); - for (const item of failingQuality.slice(0, 8)) { - lines.push(` - [${item.entry.type}] reasons=${item.quality.reasons.join(",")} ${JSON.stringify(truncate(cleanText(item.entry.text, input.raw)))}`); - } - } - - lines.push(""); - lines.push("Top rendered candidates:"); - const top = retention.rendered.slice(0, 5); - if (top.length === 0) { - lines.push(" (none)"); - } else { - for (const item of top) { - lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`); - } - } - - lines.push(""); - lines.push("Weakest active memories:"); - const weakest = retention.sorted.slice(-5).reverse(); - if (weakest.length === 0) { - lines.push(" (none)"); - } else { - for (const item of weakest) { - lines.push(` - strength=${formatStrength(item.strength)} [${item.entry.type}] ${truncate(cleanText(item.entry.text, input.raw))}`); - } - } - - return lines.join("\n"); -} diff --git a/scripts/memory-diag/formatters/missing.ts b/scripts/memory-diag/formatters/missing.ts index 0e07819..39bf741 100644 --- a/scripts/memory-diag/formatters/missing.ts +++ b/scripts/memory-diag/formatters/missing.ts @@ -1,4 +1,8 @@ -import { buildDisappearancesJSON, formatDisappearances, type DisappearanceRows } from "./disappearances.ts"; +import type { disappearanceRows } from "../inspection-model.ts"; +import { eventCounts } from "../inspection-model.ts"; +import { formatDetails } from "../text.ts"; + +export type DisappearanceRows = ReturnType; export type MissingSummary = { total: number; @@ -21,6 +25,46 @@ export function buildMissingJSON(rows: DisappearanceRows, options: { explain?: b }; } +function buildDisappearancesJSON(rows: DisappearanceRows, options: { explain?: boolean; generatedAt?: string } = {}): Record { + return { + version: 1, + generatedAt: options.generatedAt ?? new Date().toISOString(), + disappearances: rows.map(row => ({ + id: row.id, + classification: row.classification, + terminalType: row.terminalType, + reasonCodes: row.reasonCodes, + eventCounts: eventCounts(row.events), + details: options.explain ? row.event?.details : undefined, + })), + }; +} + +function formatDisappearances(rows: DisappearanceRows, options: { explain?: boolean } = {}): string { + const lines: string[] = []; + lines.push("Memory disappearances"); + lines.push(""); + if (rows.length === 0) { + lines.push("No evidence-only memories found."); + return lines.join("\n"); + } + for (const row of rows) { + const reasons = row.reasonCodes.length > 0 ? row.reasonCodes.join(",") : "none"; + lines.push(`Memory ${row.id}: ${row.classification} terminal=${row.terminalType} reasons=${reasons}`); + if (options.explain) { + lines.push(` events: ${row.events.map(event => event.type).join(", ")}`); + if (row.event?.type === "memory_removed_capacity") { + lines.push(` memory_removed_capacity details: ${formatDetails(row.event.details)}`); + } + const renderTypeCap = row.events.find(event => event.type === "render_omitted" && event.reasonCodes.includes("type_cap")); + if (renderTypeCap) { + lines.push(` render_omitted type-cap observation: reasons=${renderTypeCap.reasonCodes.join(",")} details=${formatDetails(renderTypeCap.details)}`); + } + } + } + return lines.join("\n"); +} + export function formatMissing(rows: DisappearanceRows, options: { verbose?: boolean; explain?: boolean } = {}): string { const summary = missingSummary(rows); const lines: string[] = []; diff --git a/scripts/memory-diag/formatters/quality.ts b/scripts/memory-diag/formatters/quality.ts deleted file mode 100644 index 14d5c51..0000000 --- a/scripts/memory-diag/formatters/quality.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { RETENTION_TYPE_MAX } from "../../../src/retention.ts"; -import { TYPES } from "../constants.ts"; -import { disappearanceRows } from "../inspection-model.ts"; -import { retentionCandidatesForDiag, retentionClockSummary } from "../retention-model.ts"; -import { rejectionFalsePositiveRisk, rejectionQualitySummary } from "../rejections-model.ts"; -import type { MemoryInspectionReadModel } from "../types.ts"; - -export function buildQualityJSON(model: MemoryInspectionReadModel, generatedAt = new Date().toISOString(), now = new Date(generatedAt).getTime()): Record { - const active = model.store.entries.filter(entry => entry.status !== "superseded"); - const retention = retentionCandidatesForDiag(model.store, now); - const clocks = retentionClockSummary(active); - const disappearances = disappearanceRows(model); - const evidenceCovered = active.filter(entry => (model.evidenceByMemoryId.get(entry.id) ?? []).length > 0).length; - const rejectionSummary = rejectionQualitySummary(model.rejectionRecords); - const falsePositiveRisk = rejectionFalsePositiveRisk(rejectionSummary); - const typeCounts = Object.fromEntries(TYPES.map(type => [type, active.filter(entry => entry.type === type).length])); - const capsFull = active.length >= model.store.limits.maxEntries || TYPES.some(type => (typeCounts[type] ?? 0) >= RETENTION_TYPE_MAX[type]); - const unknownDisappearances = disappearances.filter(row => row.classification === "historical_absent_unknown_reason").length; - const status = unknownDisappearances > 0 || clocks.invalid > 0 - ? "degraded" - : capsFull || rejectionSummary.legacyUnscopedCount > 0 || falsePositiveRisk === "high" - ? "warning" - : "ok"; - const summaryText = `Summary: Workspace memory quality is ${status}: ${active.length} active memories, ${evidenceCovered}/${active.length} with evidence, ${disappearances.length} evidence-only disappearances (${unknownDisappearances} unknown), ${clocks.invalid} invalid retention clocks, and ${rejectionSummary.legacyUnscopedCount} legacy unscoped rejection records.`; - const caps = { - active: active.length, - maxEntries: model.store.limits.maxEntries, - rendered: retention.rendered.length, - typeCapped: retention.typeCapped.length, - globalCapped: retention.globalCapped.length, - typeCounts, - capsFull, - }; - const evidence = { - currentWithEvidence: evidenceCovered, - currentWithoutEvidence: active.length - evidenceCovered, - evidenceMemoryIds: model.evidenceByMemoryId.size, - disappearances: disappearances.length, - unknownDisappearances, - withTerminalReason: disappearances.length - unknownDisappearances, - }; - - return { - version: 1, - generatedAt, - status, - summaryText, - store: { active: active.length, pending: model.pending.entries.length, superseded: model.store.entries.length - active.length }, - caps, - retention: clocks, - evidence, - rejections: { ...rejectionSummary, falsePositiveRisk }, - }; -} - -export function formatQuality(model: MemoryInspectionReadModel, now: number): string { - const data = buildQualityJSON(model, new Date(now).toISOString(), now) as { - summaryText: string; - caps: { - active: number; - maxEntries: number; - rendered: number; - typeCapped: number; - globalCapped: number; - typeCounts: Record; - capsFull: boolean; - }; - retention: { present: number; missing: number; invalid: number }; - evidence: { - currentWithEvidence: number; - currentWithoutEvidence: number; - evidenceMemoryIds: number; - disappearances: number; - unknownDisappearances: number; - }; - rejections: { - totalRecords: number; - workspaceScopedCount: number; - legacyUnscopedCount: number; - falsePositiveRisk: string; - }; - }; - const lines: string[] = []; - lines.push("Memory quality inspection"); - lines.push(""); - lines.push(data.summaryText); - lines.push(""); - lines.push("Caps:"); - lines.push(` active: ${data.caps.active} / ${data.caps.maxEntries}`); - for (const type of TYPES) { - const count = data.caps.typeCounts[type] ?? 0; - const limit = RETENTION_TYPE_MAX[type]; - const marker = count >= limit ? " FULL" : ""; - lines.push(` ${type}: ${count} / ${limit}${marker}`); - } - lines.push(` rendered: ${data.caps.rendered}`); - lines.push(` type-capped entries: ${data.caps.typeCapped}`); - lines.push(` global-cap overflow: ${data.caps.globalCapped}`); - lines.push(` caps full: ${data.caps.capsFull ? "yes" : "no"}`); - lines.push(""); - lines.push("Retention clocks:"); - lines.push(` present: ${data.retention.present}`); - lines.push(` missing: ${data.retention.missing}`); - lines.push(` invalid: ${data.retention.invalid}`); - lines.push(""); - lines.push("Evidence:"); - lines.push(` current with evidence: ${data.evidence.currentWithEvidence}`); - lines.push(` current without evidence: ${data.evidence.currentWithoutEvidence}`); - lines.push(` evidence memory ids: ${data.evidence.evidenceMemoryIds}`); - lines.push(` disappearances: ${data.evidence.disappearances}`); - lines.push(` unknown disappearances: ${data.evidence.unknownDisappearances}`); - lines.push(""); - lines.push("Rejection scoping:"); - lines.push(` total records: ${data.rejections.totalRecords}`); - lines.push(` workspace scoped: ${data.rejections.workspaceScopedCount}`); - lines.push(` legacy unscoped: ${data.rejections.legacyUnscopedCount}`); - lines.push(` false-positive risk: ${data.rejections.falsePositiveRisk}`); - return lines.join("\n"); -} diff --git a/scripts/memory-diag/formatters/rejections.ts b/scripts/memory-diag/formatters/rejections.ts deleted file mode 100644 index b984f22..0000000 --- a/scripts/memory-diag/formatters/rejections.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { hasSoftReason, rejectionQualitySummary } from "../rejections-model.ts"; -import { cleanPath, cleanText, countBy, formatWorkspaceIdentity, sortedCounts, truncate } from "../text.ts"; -import type { NormalizedRejection } from "../types.ts"; - -export type RejectionQualitySummary = ReturnType; - -export function buildRejectionQualityJSON(summary: RejectionQualitySummary, generatedAt = new Date().toISOString()): Record { - return { - version: 1, - generatedAt, - ...summary, - }; -} - -export function formatRejections(input: { - path: string; - invalidLines: number; - records: NormalizedRejection[]; - raw: boolean; -}): string { - const lines: string[] = []; - lines.push("Extraction rejection summary"); - lines.push(""); - lines.push(`logPath=${cleanPath(input.path, input.raw)}`); - if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`); - lines.push(""); - lines.push(`Total rejected: ${input.records.length}`); - lines.push(""); - - lines.push("By reason:"); - const byReason = sortedCounts(countBy(input.records.flatMap(record => record.reasons))); - if (byReason.length === 0) lines.push(" (none)"); - else for (const [reason, count] of byReason) lines.push(` ${reason.padEnd(24)} ${count}`); - lines.push(""); - - lines.push("By origin:"); - const byOrigin = sortedCounts(countBy(input.records.map(record => record.origin))); - if (byOrigin.length === 0) lines.push(" (none)"); - else for (const [origin, count] of byOrigin) lines.push(` ${origin.padEnd(24)} ${count}`); - lines.push(""); - - lines.push("Trigger-origin rejections (high priority for v1.5):"); - const triggerReasons = sortedCounts(countBy(input.records.filter(record => record.fromTrigger || record.origin === "explicit_trigger").flatMap(record => record.reasons))); - if (triggerReasons.length === 0) lines.push(" (none)"); - else for (const [reason, count] of triggerReasons) lines.push(` ${reason.padEnd(24)} ${count}`); - lines.push(""); - - lines.push("Recent suspicious soft rejects:"); - const suspicious = input.records - .filter(hasSoftReason) - .sort((a, b) => (new Date(b.timestamp).getTime() || 0) - (new Date(a.timestamp).getTime() || 0)) - .slice(0, 8); - if (suspicious.length === 0) { - lines.push(" (none)"); - } else { - for (const record of suspicious) { - const identity = formatWorkspaceIdentity(record.workspaceKey, record.workspaceRoot, input.raw); - lines.push(` - [${record.type}] ${JSON.stringify(truncate(cleanText(record.text, input.raw)))}`); - lines.push(` reasons: ${record.reasons.join(",")}`); - lines.push(` origin: ${record.origin}${identity ? ` (${identity})` : ""}`); - } - } - return lines.join("\n"); -} - -export function formatRejectionQuality(input: { - path: string; - invalidLines: number; - summary: RejectionQualitySummary; - raw: boolean; -}): string { - const lines: string[] = []; - lines.push("Extraction rejection quality inspection"); - lines.push(""); - lines.push("Possible false-positive grouping is heuristic, not deterministic truth."); - lines.push(`logPath=${cleanPath(input.path, input.raw)}`); - if (input.invalidLines > 0) lines.push(`Invalid JSONL lines skipped: ${input.invalidLines}`); - lines.push(""); - lines.push(`Total records: ${input.summary.totalRecords}`); - lines.push(`Unique texts: ${input.summary.uniqueTexts}`); - lines.push(`Workspace scoped: ${input.summary.workspaceScopedCount}`); - lines.push(`Legacy unscoped: ${input.summary.legacyUnscopedCount}`); - lines.push(""); - lines.push("Reason distribution (raw records):"); - for (const [reason, count] of Object.entries(input.summary.reasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`); - if (Object.keys(input.summary.reasonDistribution).length === 0) lines.push(" (none)"); - lines.push(""); - lines.push("Reason distribution (unique text):"); - for (const [reason, count] of Object.entries(input.summary.uniqueReasonDistribution)) lines.push(` ${reason.padEnd(36)} ${count}`); - if (Object.keys(input.summary.uniqueReasonDistribution).length === 0) lines.push(" (none)"); - lines.push(""); - lines.push("Possible false-positive groups (heuristic, not deterministic):"); - for (const [group, data] of Object.entries(input.summary.possibleFalsePositiveGroups)) { - lines.push(` ${group}: ${data.count}`); - for (const sample of data.samples) lines.push(` - ${JSON.stringify(cleanText(sample, input.raw))}`); - } - return lines.join("\n"); -} diff --git a/scripts/memory-diag/types.ts b/scripts/memory-diag/types.ts index 1d23d40..d2a6dfd 100644 --- a/scripts/memory-diag/types.ts +++ b/scripts/memory-diag/types.ts @@ -1,5 +1,8 @@ import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome } from "../../src/evidence-log.ts"; import type { LongTermMemoryEntry, LongTermSource, LongTermType, PendingMemoryJournalStore, WorkspaceMemoryStore } from "../../src/types.ts"; +import type { Command } from "./command-metadata.ts"; + +export type { Command, HiddenCommand, VisibleCommand } from "./command-metadata.ts"; export type MemoryRenderStatus = | "rendered" @@ -47,8 +50,6 @@ export type MemoryDiagJSON = { }>; }; -export type Command = "status" | "rejected" | "missing" | "explain" | "coverage" | "audit"; -export type LegacyCommand = "health" | "quality" | "rejections" | "disappearances" | "trace"; export type Origin = "explicit_trigger" | "compaction_candidate" | "manual" | "migration_check" | "unknown"; export type CliOptions = { @@ -61,14 +62,11 @@ export type CliOptions = { softOnly?: boolean; triggerOnly?: boolean; includeHistorical?: boolean; - quality?: boolean; reason?: string; - unique?: boolean; explain?: boolean; since?: string; migration?: string; memory?: string; - legacyCommand?: LegacyCommand; positional?: string[]; auditMode?: "coverage" | "migrations"; }; diff --git a/tests/memory-diag-cli.test.ts b/tests/memory-diag-cli.test.ts index 4451fb8..9850a4b 100644 --- a/tests/memory-diag-cli.test.ts +++ b/tests/memory-diag-cli.test.ts @@ -2,14 +2,16 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseArgs } from "../scripts/memory-diag/cli.ts"; -test("help returns usage without exiting", () => { +test("help returns usage without exposing hidden or removed commands", () => { const parsed = parseArgs(["--help"]); assert.equal(parsed.ok, true); assert.equal("help" in parsed && parsed.help, true); assert.match(parsed.usage, /Usage:/); assert.match(parsed.usage, /memory-diag \[status\]/); - assert.doesNotMatch(parsed.usage, /health/); + for (const command of ["health", "quality", "rejections", "disappearances", "trace", "coverage", "audit"]) { + assert.doesNotMatch(parsed.usage, new RegExp(command)); + } }); test("status defaults when no subcommand", () => { @@ -30,17 +32,41 @@ test("unknown command returns usage error", () => { assert.match(parsed.usage, /Usage:/); }); +test("removed legacy aliases are ordinary unknown subcommands", () => { + for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) { + const parsed = parseArgs([command]); + + assert.equal(parsed.ok, false, command); + if (parsed.ok) continue; + assert.equal(parsed.message, `Unknown subcommand: ${command}`); + assert.match(parsed.usage, /Usage:/); + } +}); + +test("hidden maintainer commands are accepted with neutral notices", () => { + const coverage = parseArgs(["coverage"]); + assert.equal(coverage.ok, true); + assert.equal("command" in coverage && coverage.command, "coverage"); + assert.equal("deprecationNotice" in coverage && coverage.deprecationNotice, "Note: 'coverage' is a maintainer-only diagnostic and is not part of the public CLI surface."); + + const audit = parseArgs(["audit"]); + assert.equal(audit.ok, true); + assert.equal("command" in audit && audit.command, "audit"); + assert.equal("deprecationNotice" in audit && audit.deprecationNotice, "Note: 'audit' is a maintainer-only diagnostic and is not part of the public CLI surface."); +}); + test("current command flag validation messages are preserved", () => { const cases: Array<{ args: string[]; message: string }> = [ - { args: ["health", "--json", "--all"], message: "health --json does not support --all" }, - { args: ["quality", "--all"], message: "quality does not accept --all" }, + { args: ["status", "--all"], message: "status does not accept --all" }, { args: ["coverage", "--all"], message: "coverage does not accept --all" }, - { args: ["disappearances", "--all"], message: "disappearances does not accept --all" }, - { args: ["rejections", "--all"], message: "rejections does not accept --all" }, + { args: ["missing", "--all"], message: "missing does not accept --all" }, + { args: ["rejected", "--all"], message: "rejected does not accept --all" }, { args: ["audit", "--workspace", "/tmp/workspace"], message: "audit does not accept --all or --workspace" }, { args: ["explain", "--all"], message: "explain does not accept --all" }, - { args: ["trace", "--all", "--memory", "mem-1"], message: "trace does not accept --all" }, - { args: ["quality", "--since", "forever"], message: "quality does not accept rejection filters" }, + { args: ["status", "--since", "forever"], message: "status does not accept rejection filters" }, + { args: ["status", "--reason", "bad_decision"], message: "status does not accept rejection filters" }, + { args: ["status", "--quality"], message: "Unknown option: --quality" }, + { args: ["rejected", "--unique"], message: "Unknown option: --unique" }, ]; for (const item of cases) { @@ -52,26 +78,8 @@ test("current command flag validation messages are preserved", () => { } }); -test("trace without memory returns current required id error", () => { - const parsed = parseArgs(["trace"]); - - assert.equal(parsed.ok, false); - if (parsed.ok) return; - assert.equal(parsed.message, "--memory requires an id"); - assert.match(parsed.usage, /Usage:/); -}); - -test("health with all and workspace returns current conflict error", () => { - const parsed = parseArgs(["health", "--all", "--workspace", "/tmp/workspace"]); - - assert.equal(parsed.ok, false); - if (parsed.ok) return; - assert.equal(parsed.message, "Use either --all or --workspace, not both"); - assert.match(parsed.usage, /Usage:/); -}); - -test("rejections invalid since value returns current error", () => { - const parsed = parseArgs(["rejections", "--since", "forever"]); +test("rejected invalid since value returns current error", () => { + const parsed = parseArgs(["rejected", "--since", "forever"]); assert.equal(parsed.ok, false); if (parsed.ok) return; @@ -79,53 +87,6 @@ test("rejections invalid since value returns current error", () => { assert.match(parsed.usage, /Usage:/); }); -test("legacy health alias emits deprecation notice and maps to status", () => { - const parsed = parseArgs(["health"]); - - assert.equal(parsed.ok, true); - assert.equal("command" in parsed && parsed.command, "status"); - assert.equal("options" in parsed && parsed.options.legacyCommand, "health"); - assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'health' is now 'status'. This alias will be removed in v2.0."); -}); - -test("legacy quality alias sets verbose and emits deprecation notice", () => { - const parsed = parseArgs(["quality"]); - - assert.equal(parsed.ok, true); - assert.equal("command" in parsed && parsed.command, "status"); - assert.equal("options" in parsed && parsed.options.verbose, true); - assert.equal("options" in parsed && parsed.options.legacyCommand, "quality"); - assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'quality' is now 'status --verbose'. This alias will be removed in v2.0."); -}); - -test("legacy rejections alias emits deprecation notice", () => { - const parsed = parseArgs(["rejections"]); - - assert.equal(parsed.ok, true); - assert.equal("command" in parsed && parsed.command, "rejected"); - assert.equal("options" in parsed && parsed.options.legacyCommand, "rejections"); - assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'rejections' is now 'rejected'. This alias will be removed in v2.0."); -}); - -test("legacy disappearances alias emits deprecation notice", () => { - const parsed = parseArgs(["disappearances"]); - - assert.equal(parsed.ok, true); - assert.equal("command" in parsed && parsed.command, "missing"); - assert.equal("options" in parsed && parsed.options.legacyCommand, "disappearances"); - assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'disappearances' is now 'missing'. This alias will be removed in v2.0."); -}); - -test("legacy trace alias emits deprecation notice", () => { - const parsed = parseArgs(["trace", "--memory", "mem-1"]); - - assert.equal(parsed.ok, true); - assert.equal("command" in parsed && parsed.command, "explain"); - assert.equal("options" in parsed && parsed.options.legacyCommand, "trace"); - assert.equal("options" in parsed && parsed.options.memory, "mem-1"); - assert.equal("deprecationNotice" in parsed && parsed.deprecationNotice, "Note: 'trace --memory ' is now 'explain '. This alias will be removed in v2.0."); -}); - test("explain accepts positional memory id", () => { const parsed = parseArgs(["explain", "mem-1", "--workspace", "/tmp/workspace"]); diff --git a/tests/memory-diag-formatters.test.ts b/tests/memory-diag-formatters.test.ts index c21bf2e..e9b34cf 100644 --- a/tests/memory-diag-formatters.test.ts +++ b/tests/memory-diag-formatters.test.ts @@ -2,15 +2,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { EvidenceEventType, EvidenceEventV1, EvidenceOutcome, EvidencePhase } from "../src/evidence-log.ts"; import type { MemoryInspectionReadModel, WorkspaceDiagSnapshot } from "../scripts/memory-diag/types.ts"; -import { formatWorkspaceHealth } from "../scripts/memory-diag/formatters/health.ts"; -import { formatQuality } from "../scripts/memory-diag/formatters/quality.ts"; import { formatCoverage } from "../scripts/memory-diag/formatters/coverage.ts"; -import { formatDisappearances } from "../scripts/memory-diag/formatters/disappearances.ts"; -import { formatRejectionQuality } from "../scripts/memory-diag/formatters/rejections.ts"; import { formatMigrationAudit } from "../scripts/memory-diag/formatters/audit.ts"; import { formatExplain } from "../scripts/memory-diag/formatters/explain.ts"; +import { buildMissingJSON, formatMissing } from "../scripts/memory-diag/formatters/missing.ts"; import { formatTrace } from "../scripts/memory-diag/formatters/trace.ts"; -import { rejectionQualitySummary } from "../scripts/memory-diag/rejections-model.ts"; function emptyInspectionModel(): MemoryInspectionReadModel { return { @@ -60,36 +56,6 @@ function event(overrides: Partial & { type: EvidenceEventType; }; } -test("health formatter includes existing retention cap label", () => { - const output = formatWorkspaceHealth({ - root: "/tmp/workspace", - key: "workspace-key", - memoryPath: "/tmp/workspace-memory.json", - pendingPath: "/tmp/workspace-pending-journal.json", - raw: false, - now: new Date("2026-01-01T00:00:00.000Z").getTime(), - includeTitle: true, - }, { rawStore: null, rawJournal: null, pendingExists: false }); - - assert.match(output, /Workspace memory health/); - assert.match(output, /Retention caps:/); -}); - -test("quality formatter includes caps and retention clock sections", () => { - const output = formatQuality(emptyInspectionModel(), new Date("2026-01-01T00:00:00.000Z").getTime()); - - assert.match(output, /Caps:/); - assert.match(output, /Retention clocks:/); -}); - -test("rejection quality formatter includes reason distribution sections", () => { - const summary = rejectionQualitySummary([]); - const output = formatRejectionQuality({ path: "/tmp/rejections.jsonl", invalidLines: 0, summary, raw: false }); - - assert.match(output, /Reason distribution \(raw records\):/); - assert.match(output, /Reason distribution \(unique text\):/); -}); - test("coverage formatter includes class counts section", () => { const output = formatCoverage([]); @@ -97,10 +63,27 @@ test("coverage formatter includes class counts section", () => { assert.match(output, /Per-memory rows:\n \(none\)/); }); -test("disappearances formatter preserves empty-state label", () => { - const output = formatDisappearances([]); +test("missing formatter verbose output preserves disappearance details", () => { + const output = formatMissing([], { verbose: true }); - assert.match(output, /No evidence-only memories found\./); + assert.match(output, /No missing memories found\./); +}); + +test("missing JSON includes disappearance rows and summary", () => { + const rows = [{ + id: "historical-1", + classification: "historical_absent_unknown_reason" as const, + terminalType: "unknown" as const, + reasonCodes: [], + events: [event({ type: "extraction_candidate_accepted", phase: "extraction", outcome: "accepted" })], + event: undefined, + }]; + + const output = buildMissingJSON(rows, { generatedAt: "2026-01-01T00:00:00.000Z" }); + + assert.equal(output.version, 1); + assert.deepEqual(output.summary, { total: 1, explained: 0, needsReview: 1 }); + assert.deepEqual((output.disappearances as Array<{ id: string }>)[0]?.id, "historical-1"); }); test("trace formatter includes lifecycle section", () => { diff --git a/tests/memory-diag.test.ts b/tests/memory-diag.test.ts index 83e0d5a..8bccf0a 100644 --- a/tests/memory-diag.test.ts +++ b/tests/memory-diag.test.ts @@ -47,8 +47,8 @@ async function writeWorkspaceStore(root: string, entries: LongTermMemoryEntry[], await writeFile(path, JSON.stringify(store, null, 2), "utf8"); } -async function runMemoryDiagHealth(root: string): Promise { - return runMemoryDiag(["health", "--workspace", root]); +async function runMemoryDiagStatusVerbose(root: string): Promise { + return runMemoryDiag(["status", "--workspace", root, "--verbose"]); } async function runMemoryDiag(args: string[]): Promise { @@ -96,27 +96,26 @@ function evidence(overrides: Partial): EvidenceEventInput { }; } -test("health handles missing workspace store as empty", async () => { +test("status handles missing workspace store as empty", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-health-")); try { - const stdout = await runMemoryDiag(["health", "--workspace", root]); + const stdout = await runMemoryDiag(["status", "--workspace", root]); - assert.match(stdout, /memory store: missing or unreadable \(treated as empty\)/); - assert.match(stdout, /pending journal: missing \(treated as empty\)/); - assert.match(stdout, /Stored active memories: 0/); - assert.match(stdout, /Pending journal:\n\s+total: 0/); + assert.match(stdout, /Memory status/); + assert.match(stdout, /active memories: 0/); + assert.match(stdout, /pending: 0/); } finally { await rm(root, { recursive: true, force: true }); } }); -test("quality handles missing workspace store as empty", async () => { +test("status verbose handles missing workspace store as empty", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-missing-quality-")); try { - const stdout = await runMemoryDiag(["quality", "--workspace", root]); + const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]); - assert.match(stdout, /Memory quality inspection/); - assert.match(stdout, /0 active memories/); + assert.match(stdout, /Memory status inspection/); + assert.match(stdout, /Caps:/); } finally { await rm(root, { recursive: true, force: true }); } @@ -136,15 +135,15 @@ test("coverage and disappearances handle missing workspace store as empty", asyn } }); -test("health with conflicting flags shows usage error", async () => { +test("status with unsupported all flag shows usage error", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-conflicting-flags-")); try { await assert.rejects( - runMemoryDiagResult(["health", "--all", "--workspace", root]), + runMemoryDiagResult(["status", "--all", "--workspace", root]), (error: unknown) => { const err = error as { code?: number; stderr?: string }; assert.notEqual(err.code, 0); - assert.match(err.stderr ?? "", /Use either --all or --workspace, not both/); + assert.match(err.stderr ?? "", /status does not accept --all/); assert.match(err.stderr ?? "", /Usage:/); return true; }, @@ -185,26 +184,21 @@ test("memory-diag defaults to status when no subcommand is supplied", async () = assert.equal(result.stderr, ""); }); -test("legacy health alias emits deprecation notice and still runs", async () => { +test("removed legacy aliases return unknown subcommand", async () => { const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-health-")); try { - const result = await runMemoryDiagResult(["health", "--workspace", root]); - - assert.match(result.stdout, /Workspace memory health/); - assert.match(result.stderr, /Note: 'health' is now 'status'\. This alias will be removed in v2\.0\./); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("legacy trace alias emits deprecation notice and still traces memory", async () => { - const root = mkdtempSync(join(tmpdir(), "opencode-memory-diag-legacy-trace-")); - try { - const result = await runMemoryDiagResult(["trace", "--memory", "test-id", "--workspace", root]); - - assert.match(result.stdout, /Memory test-id: unknown/); - assert.match(result.stdout, /Lifecycle:/); - assert.match(result.stderr, /Note: 'trace --memory ' is now 'explain '\. This alias will be removed in v2\.0\./); + for (const command of ["health", "quality", "rejections", "disappearances", "trace"]) { + await assert.rejects( + runMemoryDiagResult([command, "--workspace", root]), + (error: unknown) => { + const err = error as { code?: number; stderr?: string }; + assert.notEqual(err.code, 0); + assert.match(err.stderr ?? "", new RegExp(`Unknown subcommand: ${command}`)); + assert.match(err.stderr ?? "", /Usage:/); + return true; + }, + ); + } } finally { await rm(root, { recursive: true, force: true }); } @@ -219,11 +213,12 @@ test("memory health reports stored vs rendered retention counts", async () => { ]; await writeWorkspaceStore(root, entries); - const stdout = await runMemoryDiagHealth(root); + const stdout = await runMemoryDiagStatusVerbose(root); - assert.match(stdout, /Stored active memories:/); - assert.match(stdout, /Rendered candidates:/); - assert.match(stdout, /feedback\s+stored=17\s+rendered=10/); + assert.match(stdout, /Memory status inspection/); + assert.match(stdout, /active: 28 \/ 28/); + assert.match(stdout, /rendered: 20/); + assert.match(stdout, /feedback: 17 \/ 10 FULL/); assert.match(stdout, /Top rendered candidates:\n\s+- strength=/); } finally { await rm(root, { recursive: true, force: true }); @@ -242,15 +237,14 @@ test("memory health reports dormancy and retention monitoring deprecations", asy })); await writeWorkspaceStore(root, entries, { lastActivityAt }); - const stdout = await runMemoryDiagHealth(root); + const stdout = await runMemoryDiagStatusVerbose(root); assert.match(stdout, /Dormancy:/); assert.match(stdout, /wall days since activity: 19\.0/); assert.match(stdout, /dormant discount active: yes/); assert.match(stdout, /dormant days past grace: 5\.0/); - assert.match(stdout, /high_importance_ratio: 40\.0% .* ALERT/); - assert.match(stdout, /safety_critical_count: 6 .*deprecated.* WARNING/); - assert.match(stdout, /max_reinforced_count: 2 \(20\.0%, alert > 10%\) ALERT/); + assert.match(stdout, /Top rendered candidates:/); + assert.match(stdout, /Weakest active memories:/); } finally { await rm(root, { recursive: true, force: true }); } @@ -267,9 +261,9 @@ test("memory health reports global cap overflow separately from type caps", asyn ]; await writeWorkspaceStore(root, entries); - const stdout = await runMemoryDiagHealth(root); + const stdout = await runMemoryDiagStatusVerbose(root); - assert.match(stdout, /Rendered candidates: 28/); + assert.match(stdout, /rendered: 28/); assert.match(stdout, /type-capped entries: 0/); assert.match(stdout, /global-cap overflow: 6/); } finally { @@ -282,14 +276,13 @@ test("memory health reports missing dormancy and non-alert monitoring defaults", try { await writeWorkspaceStore(root, [], { omitLastActivityAt: true }); - const stdout = await runMemoryDiagHealth(root); + const stdout = await runMemoryDiagStatusVerbose(root); assert.match(stdout, /lastActivityAt: \(missing\)/); assert.match(stdout, /wall days since activity: unknown/); assert.match(stdout, /dormant discount active: no/); - assert.match(stdout, /high_importance_ratio: 0\.0% \(alert > 30%\)\n/); - assert.match(stdout, /safety_critical_count: 0 \(deprecated field\)\n/); - assert.match(stdout, /max_reinforced_count: 0 \(alert > 10% active\)/); + assert.match(stdout, /Top rendered candidates:\n\s+\(none\)/); + assert.match(stdout, /Weakest active memories:\n\s+\(none\)/); } finally { await rm(root, { recursive: true, force: true }); } @@ -311,8 +304,8 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu evidence({ type: "render_selected", phase: "render", outcome: "rendered", memory: { memoryId: "mem-rendered", type: "feedback", source: "explicit", status: "active" }, reasonCodes: ["within_caps", "within_char_budget"] }), ]); - const human = await runMemoryDiagHealth(root); - const jsonText = await runMemoryDiag(["health", "--workspace", root, "--json"]); + const human = await runMemoryDiagStatusVerbose(root); + const jsonText = await runMemoryDiag(["status", "--workspace", root, "--json"]); const parsed = JSON.parse(jsonText) as { version: 1; summary: { storedActive: number; rendered: number; pending: number; rejectedLast7Days: number; corruptStoresQuarantinedLast30Days: number }; @@ -321,9 +314,9 @@ test("memory health --json prints parseable privacy-safe diagnostics matching hu }; assert.equal(parsed.version, 1); - assert.equal(parsed.summary.storedActive, Number(human.match(/Stored active memories: (\d+)/)?.[1])); - assert.equal(parsed.summary.rendered, Number(human.match(/Rendered candidates: (\d+)/)?.[1])); - assert.equal(parsed.summary.pending, Number(human.match(/Pending journal:\n\s+total: (\d+)/)?.[1])); + assert.equal(parsed.summary.storedActive, Number(human.match(/active: (\d+) \/ 28/)?.[1])); + assert.equal(parsed.summary.rendered, Number(human.match(/rendered: (\d+)/)?.[1])); + assert.equal(parsed.summary.pending, 1); assert.equal(parsed.summary.rejectedLast7Days, 1); assert.equal(parsed.summary.corruptStoresQuarantinedLast30Days, 1); assert.ok(parsed.recentEvents.some(event => event.eventId && event.type === "render_selected" && event.outcome === "rendered" && event.createdAt && event.reasonCodes.includes("within_caps"))); @@ -383,7 +376,7 @@ test("memory-diag explain shows rendered, omitted, pending, and evidence reason } }); -test("memory-diag trace prints lifecycle relations and redacts secrets", async () => { +test("memory-diag explain --memory prints lifecycle relations and redacts secrets", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-")); try { await writeWorkspaceStore(root, [ @@ -399,7 +392,7 @@ test("memory-diag trace prints lifecycle relations and redacts secrets", async ( evidence({ type: "render_omitted", phase: "render", outcome: "omitted", memory: { memoryId: "mem-life", type: "decision", source: "compaction" }, relations: [{ role: "omitted", memory: { memoryId: "mem-life" } }], reasonCodes: ["superseded"] }), ]); - const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "mem-life"]); + const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "mem-life"]); assert.match(stdout, /Memory mem-life: omitted_superseded/); assert.match(stdout, /Lifecycle:/); @@ -439,21 +432,10 @@ test("memory-diag explain positional memory id prints lifecycle", async () => { } }); -test("memory-diag trace requires --memory and reports unknown IDs", async () => { +test("memory-diag explain --memory reports unknown IDs", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-trace-unknown-")); try { - await assert.rejects( - execFileAsync(process.execPath, ["--experimental-strip-types", "scripts/memory-diag.ts", "trace", "--workspace", root], { cwd: repoRoot }), - (error: unknown) => { - const err = error as { code?: number; stderr?: string }; - assert.notEqual(err.code, 0); - assert.match(err.stderr ?? "", /--memory requires an id/); - assert.match(err.stderr ?? "", /Usage:/); - return true; - }, - ); - - const stdout = await runMemoryDiag(["trace", "--workspace", root, "--memory", "missing-memory"]); + const stdout = await runMemoryDiag(["explain", "--workspace", root, "--memory", "missing-memory"]); assert.match(stdout, /Memory missing-memory: unknown/); assert.match(stdout, /Lifecycle:\n\(none\)/); } finally { @@ -481,12 +463,12 @@ async function setupQualityFixture(): Promise { return root; } -test("quality human output includes summary and aggregate inspection counts", async () => { +test("status verbose output includes summary and aggregate inspection counts", async () => { const root = await setupQualityFixture(); try { - const stdout = await runMemoryDiag(["quality", "--workspace", root]); + const stdout = await runMemoryDiag(["status", "--workspace", root, "--verbose"]); - assert.match(stdout, /Memory quality inspection/); + assert.match(stdout, /Memory status inspection/); assert.match(stdout, /Summary: Workspace memory quality is degraded:/); assert.match(stdout, /Caps:\n\s+active: 28 \/ 28/); assert.match(stdout, /decision: 14 \/ 10 FULL/); @@ -499,26 +481,18 @@ test("quality human output includes summary and aggregate inspection counts", as } }); -test("quality --json includes summaryText, caps, retention, evidence, and rejections", async () => { +test("status --json includes summary status fields", async () => { const root = await setupQualityFixture(); try { - const stdout = await runMemoryDiag(["quality", "--workspace", root, "--json"]); + const stdout = await runMemoryDiag(["status", "--workspace", root, "--json"]); const parsed = JSON.parse(stdout) as { - summaryText: string; - caps: { active: number; capsFull: boolean }; - retention: { invalid: number }; - evidence: { currentWithEvidence: number; unknownDisappearances: number }; - rejections: { workspaceScopedCount: number; legacyUnscopedCount: number }; + summary: { storedActive: number; status: string; evidenceCoveragePercent: number; needsAttention: string[] }; }; - assert.match(parsed.summaryText, /Workspace memory quality is degraded/); - assert.equal(parsed.caps.active, LONG_TERM_LIMITS.maxEntries); - assert.equal(parsed.caps.capsFull, true); - assert.equal(parsed.retention.invalid, 1); - assert.equal(parsed.evidence.currentWithEvidence, 1); - assert.equal(parsed.evidence.unknownDisappearances, 1); - assert.equal(parsed.rejections.workspaceScopedCount, 1); - assert.equal(parsed.rejections.legacyUnscopedCount, 1); + assert.equal(parsed.summary.storedActive, LONG_TERM_LIMITS.maxEntries); + assert.equal(parsed.summary.status, "degraded"); + assert.equal(typeof parsed.summary.evidenceCoveragePercent, "number"); + assert.ok(parsed.summary.needsAttention.length > 0); } finally { await rm(root, { recursive: true, force: true }); } @@ -732,25 +706,6 @@ test("missing --json includes disappearances and additive summary", async () => } }); -test("legacy disappearances --explain emits deprecation notice and detailed output", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-disappear-legacy-")); - try { - await writeWorkspaceStore(root, [entry("current", "Current memory", "feedback")]); - await appendEvidenceEvents(root, [ - evidence({ type: "promotion_promoted", phase: "promotion", outcome: "promoted", memory: { memoryId: "historical-unknown", type: "decision", source: "compaction" }, reasonCodes: ["new_workspace_entry"] }), - ]); - - const result = await runMemoryDiagResult(["disappearances", "--workspace", root, "--explain"]); - - assert.match(result.stderr, /Note: 'disappearances' is now 'missing'\. This alias will be removed in v2\.0\./); - assert.match(result.stdout, /^Memory disappearances/); - assert.match(result.stdout, /Memory historical-unknown: historical_absent_unknown_reason terminal=unknown/); - assert.match(result.stdout, /events:/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - test("rejected default output is concise with top reasons and samples", async () => { const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejected-")); try { @@ -822,56 +777,3 @@ test("rejected --json includes quality summary and false-positive risk", async ( await rm(root, { recursive: true, force: true }); } }); - -test("rejections --quality --reason bad_decision --unique groups architecture-like samples heuristically", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-quality-")); - try { - const key = await workspaceKey(root); - const now = new Date().toISOString(); - await writeRejectionRecords([ - { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Retention scoring model uses evidence caps to avoid normalization drift", reasons: ["bad_decision"] }, - { timestamp: now, workspaceKey: key, type: "decision", source: "compaction", text: "Implemented phase 2 and updated tests", reasons: ["bad_decision"] }, - { timestamp: now, type: "decision", source: "compaction", text: "Maybe useful", reasons: ["bad_decision"] }, - ]); - - const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--reason", "bad_decision", "--unique"]); - - assert.match(stdout, /Extraction rejection quality inspection/); - assert.match(stdout, /Possible false-positive grouping is heuristic, not deterministic truth/); - assert.match(stdout, /architecture_like_possible_false_positive: 1/); - assert.match(stdout, /clearly_garbage: 1/); - assert.match(stdout, /ambiguous: 1/); - assert.doesNotMatch(stdout, /deterministic truth\s*:/); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test("rejections --quality --json includes scoping, unique reasons, and possible false-positive groups", async () => { - const root = await mkdtemp(join(tmpdir(), "opencode-memory-diag-rejections-json-")); - try { - const key = await workspaceKey(root); - const now = new Date().toISOString(); - await writeRejectionRecords([ - { timestamp: now, workspaceKey: key, workspaceRootHash: "hash", type: "decision", source: "compaction", text: "Memory system schema boundary should remain stable", reasons: ["bad_decision"] }, - { timestamp: now, type: "feedback", source: "compaction", text: "Wave 1 completed successfully", reasons: ["progress_snapshot", "bad_feedback"] }, - ]); - - const stdout = await runMemoryDiag(["rejections", "--quality", "--workspace", root, "--json"]); - const parsed = JSON.parse(stdout) as { - workspaceScopedCount: number; - legacyUnscopedCount: number; - falsePositiveRisk: string; - uniqueReasonDistribution: Record; - possibleFalsePositiveGroups: Record; - }; - - assert.equal(parsed.workspaceScopedCount, 1); - assert.equal(parsed.legacyUnscopedCount, 1); - assert.equal(parsed.falsePositiveRisk, "high"); - assert.equal(parsed.uniqueReasonDistribution.bad_decision, 1); - assert.equal(parsed.possibleFalsePositiveGroups.architecture_like_possible_false_positive.count, 1); - } finally { - await rm(root, { recursive: true, force: true }); - } -});