From 3f93b2c110a69c03495ff909884deab20d531ce9 Mon Sep 17 00:00:00 2001 From: Hemang Doshi Date: Mon, 29 Jun 2026 15:14:37 +0530 Subject: [PATCH] fix: pack salience planner, config-driven preflight enforcement, vector read-only validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D — Pack Salience Planner: - New src/formatters/pack-planner.ts: planPackMemories() selects by salience - Mandatory: pinned, priority>0, blocking command_policy, high-severity fragile/failed/known_fix/agent_mistake - Optional sorted by retrieval score; omitted memories summarized without content - Wired into formatPackMarkdown() replacing line-by-line truncation Phase F — Preflight Enforce Config: - preflight --enforce now uses config.preflight.enforce_warn_exit_code and enforce_block_exit_code - Was hardcoded to 1/2 previously Phase G — Vector Read-Only Validation: - searchVectorIndexReadOnly() now validates projectId, provider name, and dimensions - Returns empty when mismatch detected Phase H — ProjectMind Scenarios 5-6: - architecture-decision: proves memory surfaces for DB-related task - supersession-visibility: proves old hidden, replacement quaranatinable - 6/6 scenarios pass in isolated temp workspaces --- src/cli/main.ts | 30 ++++++----- src/evals/projectmind/projectmind.ts | 66 +++++++++++++++++++++++ src/formatters/pack-markdown.ts | 27 ++++++++-- src/formatters/pack-planner.ts | 80 ++++++++++++++++++++++++++++ src/vector/vector-index.ts | 10 ++++ 5 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 src/formatters/pack-planner.ts diff --git a/src/cli/main.ts b/src/cli/main.ts index 13f4345..69f58c5 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -863,23 +863,27 @@ async function main(): Promise { } case "preflight": { const commandValue = requireOption(parsed, "command"); - const result = await preflightCommand({ - cwd, - command: commandValue, - sessionId: typeof parsed.options.session === "string" ? parsed.options.session : undefined - }); - const enforce = Boolean(parsed.options.enforce); + const loaded = await import("../core/context.js").then((m) => m.loadProject(cwd)); + try { + const result = await preflightCommand({ + cwd, + command: commandValue, + sessionId: typeof parsed.options.session === "string" ? parsed.options.session : undefined + }); + const enforce = Boolean(parsed.options.enforce); - render(asJson ? result : formatTextPreflight(result), asJson); + render(asJson ? result : formatTextPreflight(result), asJson); - if (enforce) { - if (result.decision === "block") { - process.exitCode = 2; - } else if (result.decision === "warn") { - process.exitCode = 1; + if (enforce) { + if (result.decision === "block") { + process.exitCode = loaded.context.config.preflight.enforce_block_exit_code; + } else if (result.decision === "warn") { + process.exitCode = loaded.context.config.preflight.enforce_warn_exit_code; + } } + } finally { + loaded.close(); } - return; } case "run": { diff --git a/src/evals/projectmind/projectmind.ts b/src/evals/projectmind/projectmind.ts index 201b41d..bbbfce7 100644 --- a/src/evals/projectmind/projectmind.ts +++ b/src/evals/projectmind/projectmind.ts @@ -159,6 +159,72 @@ export async function runProjectMindEval(): Promise { }); } + // Scenario 5: architecture-decision + { + const noMemoryCwd = await tempWorkspace("ad-no-mem-"); + workspaces.push(noMemoryCwd); + await initProject({ cwd: noMemoryCwd }); + const withMemoryCwd = await tempWorkspace("ad-with-mem-"); + workspaces.push(withMemoryCwd); + await initProject({ cwd: withMemoryCwd }); + await createMemory({ + cwd: withMemoryCwd, + content: "Use repository layer; no direct DB access from route handlers.", + type: "architecture_note", + source: "user_explicit", + severity: "high" + }); + + const noMem = await retrieveMemories({ cwd: noMemoryCwd, task: "update package.json", dryRun: true }); + const withMem = await retrieveMemories({ cwd: withMemoryCwd, task: "add direct DB query to the user route handler", dryRun: true }); + const hasDecision = withMem.some((m) => m.type === "architecture_note"); + + scenarios.push({ + name: "architecture-decision", + passed: !noMem.some((m) => m.type === "architecture_note") && hasDecision, + noMemory: { memoryCount: noMem.length }, + withMemory: { memoryCount: withMem.length, includesArchitectureNote: hasDecision }, + delta: hasDecision ? "architecture decision surfaced for DB-related task" : "no delta" + }); + } + + // Scenario 6: supersession-visibility + { + const cwd = await tempWorkspace("ss-mem-"); + workspaces.push(cwd); + await initProject({ cwd }); + const old = await createMemory({ + cwd, + content: "Use npm for package management.", + type: "workflow_rule", + source: "user_explicit" + }); + const replacement = await createMemory({ + cwd, + content: "Use pnpm for all package operations.", + type: "workflow_rule", + source: "user_explicit" + }); + const { supersedeMemory } = await import("../../lifecycle/lifecycle.js"); + await supersedeMemory({ cwd, oldMemoryId: old.id, newMemoryId: replacement.id, reason: "Updated to pnpm" }); + + let results = await retrieveMemories({ cwd, task: "package manager", dryRun: true }); + const oldHidden = !results.some((m) => m.id === old.id); + const replacementVisible = results.some((m) => m.id === replacement.id); + + await quarantineMemory({ cwd, memoryId: replacement.id, reason: "Test quarantine" }); + results = await retrieveMemories({ cwd, task: "package manager", dryRun: true }); + const replacementHidden = !results.some((m) => m.id === replacement.id); + + scenarios.push({ + name: "supersession-visibility", + passed: oldHidden && replacementVisible && replacementHidden, + noMemory: {}, + withMemory: { oldSupersededAndHidden: oldHidden, replacementVisible, replacementHiddenAfterQuarantine: replacementHidden }, + delta: "superseded memory hidden; superseding memory hidden after quarantine" + }); + } + const passed = scenarios.filter((s) => s.passed).length; const failed = scenarios.filter((s) => !s.passed).length; diff --git a/src/formatters/pack-markdown.ts b/src/formatters/pack-markdown.ts index d5d6ddf..60fe7ea 100644 --- a/src/formatters/pack-markdown.ts +++ b/src/formatters/pack-markdown.ts @@ -1,4 +1,5 @@ import type { MemoryRecord } from "../domain/types.js"; +import { planPackMemories } from "./pack-planner.js"; export interface PackSectionItem { id: string; @@ -136,17 +137,33 @@ export function formatPackMarkdown( memories: MemoryRecord[], options: { generatedAt?: string; budgetCharacters?: number } = {} ): string { - const sections = buildPackSections(memories); + let selected = memories; + let omittedSummary = ""; + + if (options.budgetCharacters !== undefined && options.budgetCharacters > 0) { + const headerChars = renderHeader(projectName, options.generatedAt).join("\n").length + 50; + const plan = planPackMemories({ + memories, + budgetCharacters: options.budgetCharacters, + reservedCharacters: headerChars + }); + selected = plan.selected; + + if (plan.omitted.length > 0) { + omittedSummary = `\n## Omitted Memories\n- ${plan.omitted.length} lower-priority memories omitted due to configured memory budget.\n`; + } + } + + const sections = buildPackSections(selected); const hasConstraints = sections.some((section) => section.title === "Critical Constraints"); const markdown = [ ...renderHeader(projectName, options.generatedAt), ...sections.map(renderSection), - hasConstraints ? "## Suggested Verification\n- Prefer safe local checks before risky commands.\n" : "" + hasConstraints ? "## Suggested Verification\n- Prefer safe local checks before risky commands.\n" : "", + omittedSummary ] .filter(Boolean) .join("\n"); - return options.budgetCharacters === undefined - ? markdown - : truncateMarkdown(markdown, options.budgetCharacters); + return markdown; } diff --git a/src/formatters/pack-planner.ts b/src/formatters/pack-planner.ts new file mode 100644 index 0000000..9d11654 --- /dev/null +++ b/src/formatters/pack-planner.ts @@ -0,0 +1,80 @@ +import type { MemoryRecord } from "../domain/types.js"; + +export interface PackPlan { + selected: MemoryRecord[]; + omitted: Array<{ + memoryId: string; + type: MemoryRecord["type"]; + reason: string; + }>; +} + +function isMandatory(memory: MemoryRecord): boolean { + if (memory.pinned) return true; + if (memory.priority > 0) return true; + if (memory.type === "command_policy" && memory.metadata.decision === "block") return true; + if ( + memory.severity === "high" && + (memory.type === "fragile_file" || + memory.type === "failed_attempt" || + memory.type === "known_fix" || + memory.type === "agent_mistake") + ) + return true; + return false; +} + +function estimatedCharSize(memory: MemoryRecord): number { + return memory.content.length + 80; +} + +export function planPackMemories({ + memories, + budgetCharacters, + reservedCharacters = 0 +}: { + memories: MemoryRecord[]; + budgetCharacters: number; + reservedCharacters?: number; +}): PackPlan { + const available = budgetCharacters - reservedCharacters; + if (available <= 0) { + return { selected: [], omitted: memories.map((m) => ({ memoryId: m.id, type: m.type, reason: "No budget remaining" })) }; + } + + const mandatory = memories.filter(isMandatory); + const optional = memories + .filter((m) => !isMandatory(m)) + .sort((a, b) => { + const scoreA = typeof a.metadata.retrieval === "object" && a.metadata.retrieval && "score" in a.metadata.retrieval + ? (a.metadata.retrieval.score as number) : 0; + const scoreB = typeof b.metadata.retrieval === "object" && b.metadata.retrieval && "score" in b.metadata.retrieval + ? (b.metadata.retrieval.score as number) : 0; + return scoreB - scoreA || b.createdAt.localeCompare(a.createdAt); + }); + + const selected: MemoryRecord[] = []; + let used = 0; + + for (const memory of mandatory) { + selected.push(memory); + used += estimatedCharSize(memory); + } + + for (const memory of optional) { + if (used >= available) break; + selected.push(memory); + used += estimatedCharSize(memory); + } + + const selectedIds = new Set(selected.map((m) => m.id)); + const omitted = memories + .filter((m) => !selectedIds.has(m.id)) + .map((m) => ({ + memoryId: m.id, + type: m.type, + reason: "Lower priority — omitted due to configured memory budget" + })); + + return { selected, omitted }; +} diff --git a/src/vector/vector-index.ts b/src/vector/vector-index.ts index 678aadf..3f4f1f4 100644 --- a/src/vector/vector-index.ts +++ b/src/vector/vector-index.ts @@ -197,6 +197,7 @@ export function searchVectorIndexReadOnly({ query, limit, visibleMemoryIds, + projectId, providerId = "local" }: { memoryDir: string; @@ -212,6 +213,15 @@ export function searchVectorIndexReadOnly({ if (!index || index.entries.length === 0) { return []; } + if (index.projectId !== projectId) { + return []; + } + if (index.provider !== provider.name) { + return []; + } + if (index.dimensions !== provider.dimensions) { + return []; + } const queryEmbedding = provider.embed(query); return index.entries .filter((entry) => visibleMemoryIds.has(entry.memoryId))