Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/artifacts/TECHNICAL_DEBT-wave26.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| # | Finding | Location | Fix |
|---|---|---|---|
| W26-1 | Two file→memory resolvers (`recallAnchoredMemories` vs `resolveMemoryForCode`) with different matching | `src/retrieval/anchors.ts`, `src/orchestrator/bidirectional.ts` | Pick one owner; extend it with project + freshness |
| W26-1 | ~~Two file→memory resolvers~~ **done**: canonical `resolveMemoriesForCode`; pre-flight and PR context are adapters | `src/retrieval/code_context.ts` | — |
| W26-2 | Pre-flight token budget covers entries only; constraints/CORE blocks are appended uncounted | `src/hooks/preflight.ts`, `src/retrieval.ts` | Count/trim the final markdown or rename the claim to "entry budget" |
| W26-3 | ~~Pre-flight is CLI-only~~ **done**: PreToolUse hook + read-only `muse_preflight` MCP tool | `src/hooks/pack.ts`, `src/mcp.ts` | — |
| W26-4 | `associated` is ids-only and not fused into ranking/token accounting | `src/retrieval.ts` | Fuse with `why_retrieved` vocabulary or document the consumer contract |
Expand Down
1 change: 1 addition & 0 deletions .agents/context/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ on any blocking failure, so CI runs it. Report:
---

## 🏁 Recently Landed Waves (Verified on `dev`)
- [x] **Unified code resolver** (2026-11-09) — `resolveMemoriesForCode` is the single matcher; pre-flight and PR context adapt it.
- [x] **Pre-flight surfaces** (2026-11-09) — PreToolUse hook + read-only `muse_preflight` MCP tool; 119 tools.
- [x] **Wave 26 (`T-432`–`T-435`)** (2026-11-09) — recursive-CTE associative expansion (`T-432`, direct `dev`), pre-flight context (`T-433`, #370), blast-radius risk (`T-434`, #371), anchor-matched auto-recall (`T-435`, #372). Tests 1495/0.
- [x] **Site build + scaffolding wiring** (2026-11-08) — PR #367 (Node Astro launcher; site:qa 17/17 SHIP) and #368 (briefing episodes, explicit `asserted_at` belief clock in `as_of`, duplicate decay removed).
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **Unified file→memory resolver**: pre-flight anchored invariants and PR context now share one project/trust/freshness-aware matcher, ranking and reason tokens.
- **Pre-flight surfaces**: a matcher-scoped `PreToolUse` hook and read-only `muse_preflight` MCP tool expose anchored invariants, blast-radius risk and a token-budgeted pack before edits.
- **Wave 26 associative retrieval**: recursive-CTE associative expansion (`memory recall --associative`), speculative pre-flight context (`memory preflight`) with blast-radius risk scoring, and anchor-matched invariant auto-recall.
- **Episode fidelity and `muse_episodes`**: observation provenance no longer classifies failed test commands as verifications; explicit metadata phases win; recent causal episodes are available over MCP.
Expand Down
65 changes: 21 additions & 44 deletions src/orchestrator/bidirectional.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Store } from "../store.ts";
import { get, list } from "../store.ts";
import type { MemoryEntry } from "../types.ts";
import type { CodeForMemoryResult, MemoryForCodeResult } from "./types.ts";
import { resolveMemoriesForCode } from "../retrieval/code_context.ts";

/**
* Given a memory ID, returns all anchored code references, files, and symbols.
Expand Down Expand Up @@ -51,59 +52,35 @@ export function resolveCodeForMemory(
*/
export function resolveMemoryForCode(
store: Store,
options: { filePath: string; symbolName?: string }
options: { filePath: string; symbolName?: string; project?: string }
): MemoryForCodeResult {
const allEntries = list(store);
const normalizedFile = options.filePath.toLowerCase();
const normalizedSymbol = options.symbolName ? options.symbolName.toLowerCase() : undefined;
// W26-1: shared resolver. PR context keeps prose mentions and untrusted
// entries for a superset view, but matching/ranking lives in one place.
const matches = resolveMemoriesForCode(store, {
files: [options.filePath],
symbols: options.symbolName ? [options.symbolName] : undefined,
project: options.project,
includeMentions: true,
includeUntrusted: true,
enforceFreshness: false,
limit: 50,
});

const associated: MemoryEntry[] = [];
const negative: MemoryEntry[] = [];
const constraints: MemoryEntry[] = [];

for (const entry of allEntries) {
if (entry.status === "archived" || entry.status === "superseded") continue;

let isMatch = false;

// 1. Check native code anchors
if (entry.anchors && entry.anchors.length > 0) {
for (const anc of entry.anchors) {
const ancFile = anc.file_path.toLowerCase();
const fileMatch = ancFile.includes(normalizedFile) || normalizedFile.includes(ancFile);
const symbolMatch =
normalizedSymbol && anc.symbol_name
? anc.symbol_name.toLowerCase() === normalizedSymbol
: false;

if (fileMatch || symbolMatch) {
isMatch = true;
break;
}
}
}

// 2. Check content or title references if not already matched
if (!isMatch) {
const lowerContent = `${entry.title} ${entry.content}`.toLowerCase();
if (lowerContent.includes(normalizedFile)) {
isMatch = true;
} else if (normalizedSymbol && lowerContent.includes(normalizedSymbol)) {
isMatch = true;
}
}

if (isMatch) {
if (entry.negative != null || entry.tags?.some((t) => t.includes("negative") || t.includes("anti-pattern"))) {
negative.push(entry);
} else if (entry.type === "constraint" || entry.temporal_mode === "timeless") {
constraints.push(entry);
} else {
associated.push(entry);
}
for (const { entry } of matches) {
if (entry.negative != null || entry.tags?.some((t) => t.includes("negative") || t.includes("anti-pattern"))) {
negative.push(entry);
} else if (entry.type === "constraint" || entry.temporal_mode === "timeless") {
constraints.push(entry);
} else {
associated.push(entry);
}
}


return {
file_path: options.filePath,
symbol_name: options.symbolName,
Expand Down
70 changes: 12 additions & 58 deletions src/retrieval/anchors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Store } from "../store.ts";
import { list } from "../store.ts";
import type { ScoredEntry } from "../retrieval.ts";
import { resolveMemoriesForCode } from "./code_context.ts";

export interface AnchorRecallInput {
files?: string[];
Expand All @@ -10,64 +10,18 @@ export interface AnchorRecallInput {
limit?: number;
}

const PRIORITY: Record<string, number> = {
constraint: 1.0,
adr: 0.95,
architecture: 0.9,
failure: 0.85,
negative: 0.85,
};

function normalizePath(value: string): string {
return value.replace(/\\/g, "/").replace(/^\.?\//, "");
}

function fileMatches(anchorPath: string, queryPath: string): boolean {
const a = normalizePath(anchorPath);
const b = normalizePath(queryPath);
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
}

/**
* T-435: AST-anchored invariant auto-recall. Given the files/symbols being
* edited, surface the constraints, ADRs and architecture memories anchored to
* them before the agent changes anything.
* T-435: anchored invariants for pre-flight. Adapter over the canonical
* resolver (W26-1) with anchor-only matching and freshness enforced.
*/
export function recallAnchoredMemories(store: Store, input: AnchorRecallInput): ScoredEntry[] {
const files = input.files ?? [];
const symbols = input.symbols ?? [];
if (files.length === 0 && symbols.length === 0) return [];

const matches: ScoredEntry[] = [];
for (const entry of list(store)) {
if (entry.status !== "active" && entry.status !== "confirmed") continue;
if (input.project && entry.project !== input.project) continue;
if (!input.includeUntrusted && entry.trust === "untrusted") continue;
if (entry.valid_to && Date.parse(entry.valid_to) <= Date.now()) continue;
if (entry.expires_at && Date.parse(entry.expires_at) <= Date.now()) continue;
const hit = entry.anchors?.find((anchor) => {
if (anchor.status === "orphaned" || anchor.status === "drifted") return false;
if (anchor.file_path && files.some((file) => fileMatches(anchor.file_path, file))) return true;
const name = anchor.symbol_name ?? anchor.qualified_name;
return Boolean(name && symbols.some((symbol) => symbol.toLowerCase() === name.toLowerCase()));
});
if (!hit) continue;
const base = entry.type ? PRIORITY[entry.type] ?? 0.7 : 0.7;
const verified = entry.verification?.level && entry.verification.level !== "unverified" ? 0.05 : 0;
matches.push({
entry,
score: Math.min(1, base + verified),
why_retrieved: [`anchor:${hit.kind}:${hit.file_path || hit.symbol_name || hit.qualified_name || hit.id}`],
});
}

return matches
.sort((a, b) => {
const byScore = b.score - a.score;
if (byScore !== 0) return byScore;
const at = Date.parse(a.entry.updated_at);
const bt = Date.parse(b.entry.updated_at);
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at);
})
.slice(0, Math.min(Math.max(input.limit ?? 10, 1), 50));
return resolveMemoriesForCode(store, {
files: input.files,
symbols: input.symbols,
project: input.project,
includeUntrusted: input.includeUntrusted,
enforceFreshness: true,
includeMentions: false,
limit: input.limit,
});
}
95 changes: 95 additions & 0 deletions src/retrieval/code_context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { Store } from "../store.ts";
import { list } from "../store.ts";
import type { ScoredEntry } from "../retrieval.ts";
import type { MemoryEntry } from "../types.ts";

export interface CodeContextInput {
files?: string[];
symbols?: string[];
project?: string;
includeMentions?: boolean;
includeUntrusted?: boolean;
/** Apply anchor freshness, validity and expiry gates (pre-flight default). */
enforceFreshness?: boolean;
limit?: number;
}

const PRIORITY: Record<string, number> = {
constraint: 1.0,
adr: 0.95,
architecture: 0.9,
failure: 0.85,
negative: 0.85,
};

const HIDDEN_STATUSES = new Set(["superseded", "rejected", "conflicted"]);

export function normalizeCodePath(value: string): string {
return value.replace(/\\/g, "/").replace(/^\.?\//, "").toLowerCase();
}

function fileMatches(anchorPath: string, queryPath: string): boolean {
const a = normalizeCodePath(anchorPath);
const b = normalizeCodePath(queryPath);
return a === b || a.endsWith(`/${b}`) || b.endsWith(`/${a}`);
}

function isUnfresh(entry: MemoryEntry, now: number): boolean {
if (entry.valid_to && Date.parse(entry.valid_to) <= now) return true;
if (entry.expires_at && Date.parse(entry.expires_at) <= now) return true;
return (entry.anchors ?? []).some((anchor) => anchor.status === "orphaned" || anchor.status === "drifted");
}

/**
* W26-1: the single file/symbol → memory resolver. Both the pre-flight
* anchored-invariant surface and the PR-context resolver are adapters over
* this function, so matching, ranking and reason tokens cannot drift apart.
*/
export function resolveMemoriesForCode(store: Store, input: CodeContextInput): ScoredEntry[] {
const files = input.files ?? [];
const symbols = input.symbols ?? [];
if (files.length === 0 && symbols.length === 0) return [];
const now = Date.now();
const matched: ScoredEntry[] = [];

for (const entry of list(store)) {
if (entry.status === "archived") continue;
if (HIDDEN_STATUSES.has(entry.status)) continue;
if (input.project && entry.project !== input.project) continue;
if (!input.includeUntrusted && entry.trust === "untrusted") continue;
if (input.enforceFreshness !== false && isUnfresh(entry, now)) continue;

let reason: string | undefined;
const anchor = (entry.anchors ?? []).find((candidate) => {
if (candidate.status === "orphaned" || candidate.status === "drifted") return false;
if (candidate.file_path && files.some((file) => fileMatches(candidate.file_path, file))) return true;
const name = candidate.symbol_name ?? candidate.qualified_name;
return Boolean(name && symbols.some((symbol) => symbol.toLowerCase() === name.toLowerCase()));
});
if (anchor) {
reason = `anchor:${anchor.kind}:${anchor.file_path || anchor.symbol_name || anchor.qualified_name || anchor.id}`;
} else if (input.includeMentions) {
const haystack = `${entry.title} ${entry.content}`.toLowerCase();
const file = files.find((candidate) => haystack.includes(normalizeCodePath(candidate)));
const symbol = symbols.find((candidate) => haystack.includes(candidate.toLowerCase()));
if (file) reason = `mention:file:${file}`;
else if (symbol) reason = `mention:symbol:${symbol}`;
}
if (!reason) continue;

const base = entry.type ? PRIORITY[entry.type] ?? 0.7 : 0.7;
const anchorBoost = reason.startsWith("anchor:") ? 0.1 : 0;
const verified = entry.verification?.level && entry.verification.level !== "unverified" ? 0.05 : 0;
matched.push({ entry, score: Math.min(1, base + anchorBoost + verified), why_retrieved: [reason] });
}

return matched
.sort((a, b) => {
const byScore = b.score - a.score;
if (byScore !== 0) return byScore;
const at = Date.parse(a.entry.updated_at);
const bt = Date.parse(b.entry.updated_at);
return (Number.isNaN(bt) ? 0 : bt) - (Number.isNaN(at) ? 0 : at);
})
.slice(0, Math.min(Math.max(input.limit ?? 10, 1), 50));
}
40 changes: 40 additions & 0 deletions test/code_context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, test, expect } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openStore, propose, save, get } from "../src/store.ts";
import { resolveMemoriesForCode } from "../src/retrieval/code_context.ts";
import { recallAnchoredMemories } from "../src/retrieval/anchors.ts";
import { resolveMemoryForCode } from "../src/orchestrator/bidirectional.ts";

describe("W26-1: one file→memory resolver", () => {
test("anchor-only, mention and project scoping agree across adapters", () => {
const memoryDir = mkdtempSync(join(tmpdir(), "muse-code-context-"));
try {
const store = openStore(memoryDir);
const constraint = propose(store, { content: "auth tokens must rotate", project: "p", type: "constraint", confirmed: true });
save(store, { ...get(store, constraint.id)!, anchors: [{ id: "a1", kind: "file", file_path: "src/auth.ts" }] });

const prose = propose(store, { content: "see src/auth.ts before editing the session", project: "p", type: "discovery", confirmed: true });
const foreign = propose(store, { content: "foreign auth rule", project: "other", type: "constraint", confirmed: true });
save(store, { ...get(store, foreign.id)!, anchors: [{ id: "a2", kind: "file", file_path: "src/auth.ts" }] });

const anchored = resolveMemoriesForCode(store, { files: ["src/auth.ts"], project: "p" });
expect(anchored.map((item) => item.entry.id)).toEqual([constraint.id]);
expect(anchored[0].why_retrieved?.[0]).toContain("anchor:file");

const withMentions = resolveMemoriesForCode(store, { files: ["src/auth.ts"], project: "p", includeMentions: true });
expect(withMentions.map((item) => item.entry.id)).toContain(prose.id);
expect(withMentions[0].entry.id).toBe(constraint.id);

const preflight = recallAnchoredMemories(store, { files: ["src/auth.ts"], project: "p" });
const prContext = resolveMemoryForCode(store, { filePath: "src/auth.ts", project: "p" });
expect(preflight.map((item) => item.entry.id)).toEqual([constraint.id]);
expect(prContext.constraints.map((entry) => entry.id)).toContain(constraint.id);
expect(prContext.constraints.map((entry) => entry.id)).not.toContain(foreign.id);
expect(prContext.associated_memories.map((entry) => entry.id)).toContain(prose.id);
} finally {
rmSync(memoryDir, { recursive: true, force: true });
}
});
});
Loading