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 @@ -4,6 +4,6 @@
|---|---|---|---|
| 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-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; hook pack and MCP do not call it | `src/hooks/pack.ts`, `src/mcp.ts` | Wire file-open/branch-switch hooks and a read-only MCP surface |
| 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 |
| W26-5 | Recursive CTE work is not bounded by `maxNodes` before projection | `src/associative.ts` | Push the limit into the recursive member |
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] **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).
- [x] **Wave 24–25 review hardening** (2026-11-08) — PR #366 merged at `a485875`; playbook rail preservation, fail-loud corrupt store, id-keyed updates, unique episode ids, goal-first phases, epoch timestamps, CLI flags, doc honesty. Gauntlet: REJECT → fixed, CAPPED; website visual gate blocked by pre-existing Astro/cookie prerender error.
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
- **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.
- **Studio left rail (`T-451`)**: fixed 76px numbered navigation (`01 OVERVIEW`–`06 ABOUT`), warm-dark palette, Archivo Black + JetBrains Mono, and an About anchor; all prior views remain reachable from a tools group.
Expand Down
8 changes: 4 additions & 4 deletions claims.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$comment": "Generated by scripts/generate-claims.ts. Do not hand-edit: test/marketing_claims.test.ts re-derives every value from src/ and fails on drift.",
"version": "3.2.0",
"mcpTools": 118,
"mcpTools": 119,
"mcpProfiles": 7,
"agentPlatforms": 52,
"migratorProviders": 30,
Expand All @@ -14,8 +14,8 @@
"networkMillis": 300
},
"tests": {
"count": 1495,
"assertions": 7172,
"files": 231
"count": 1497,
"assertions": 7177,
"files": 232
}
}
7 changes: 4 additions & 3 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Version Control (R23):

## MCP tools

**118 tools** are registered, exposed through **7 task-focused profiles** (`core`, `coding`, `debugging`, `review`, `architecture`, `maintenance`, `full`). The authoritative machine-readable list is [`mcp-tool-matrix.json`](./mcp-tool-matrix.json).
**119 tools** are registered, exposed through **7 task-focused profiles** (`core`, `coding`, `debugging`, `review`, `architecture`, `maintenance`, `full`). The authoritative machine-readable list is [`mcp-tool-matrix.json`](./mcp-tool-matrix.json).

Select a profile with the `MUSE_MCP_PROFILE` environment variable. Call `muse_profile_list` for the live mapping.

Expand Down Expand Up @@ -227,7 +227,7 @@ Select a profile with the `MUSE_MCP_PROFILE` environment variable. Call `muse_pr
| `muse_why` | `target` | Autonomous Engineering Cognition: Explain WHY code was designed or modified the way it is by tracing historical bug fixes, ADRs, trade-offs, and invariants |
| `muse_bug_clusters` | — | Analyze and cluster recurring bug fixes, negative lessons, and failures into architectural fragility hotspots with root-cause hypotheses |
| `muse_tech_debt` | — | Scan repository and memory store for technical debt indicators (TODO/FIXME/HACK, unsafe 'as any' casts, drifted code anchors) and generate refactoring priorities |
| `muse_health` | `packet` | Unified 5-Pillar Project Health Gate: audit memory store integrity, code anchor validity, documentation drift, anti-pattern sentry, and technical debt with overall grade (A-F) and pass/fail gate status |
| `muse_health` | — | Unified 5-Pillar Project Health Gate: audit memory store integrity, code anchor validity, documentation drift, anti-pattern sentry, and technical debt with overall grade (A-F) and pass/fail gate status |
| `muse_code_impact` | `file_path` | Evaluates comprehensive code and memory impact before editing: callers, test suites, ADRs, negative warnings, active constraints, and composite risk level |
| `muse_pr_context` | — | Generates rich GitHub PR descriptions from git diff linking touched code anchors, respected ADRs, tested invariants, and composite risk assessment |
| `muse_reconcile_anchors` | — | Interactively audits, prunes, or updates drifted and orphaned code anchors across memories when code is refactored or deleted |
Expand Down Expand Up @@ -267,10 +267,11 @@ Select a profile with the `MUSE_MCP_PROFILE` environment variable. Call `muse_pr

| Tool | Required parameters | Description |
| --- | --- | --- |
| `muse_preflight` | — | Read-only pre-flight: anchored invariants, blast-radius risk and a token-budgeted context pack for the files about to be edited |
| `muse_episodes` | `packet` | Recall recent episodic narratives (goal → hypothesis → trajectory → verification → retrospective) built from observations |
| `muse_playbook_recall` | `packet` | Recall procedural playbooks: list by tag, or fetch one by id/title |
| `muse_playbook_record` | `packet` | Save a playbook trajectory, or record success/failure for an existing playbook |

---

Counts in this file are generated from the same source as [`claims.json`](../claims.json): 118 tools, 7 profiles, 30 import formats, 8 credential rule families.
Counts in this file are generated from the same source as [`claims.json`](../claims.json): 119 tools, 7 profiles, 30 import formats, 8 credential rule families.
3 changes: 2 additions & 1 deletion docs/mcp-tool-matrix.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Generated by scripts/generate-claims.ts — the authoritative MCP tool list.",
"count": 118,
"count": 119,
"tools": [
"confirm_fix",
"get_context",
Expand Down Expand Up @@ -110,6 +110,7 @@
"muse_playbook_recall",
"muse_playbook_record",
"muse_pr_context",
"muse_preflight",
"muse_profile_list",
"muse_reconcile_anchors",
"muse_sync_broadcast",
Expand Down
7 changes: 5 additions & 2 deletions src/cli/retrieval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export async function handleHookCommand({ positional, flags }: ParsedArgs): Prom
*/
export async function handleHooksCommand({ positional, flags }: ParsedArgs): Promise<number> {
const sub = positional[0] ?? "install";
const { installHookPack, uninstallHookPack, runSessionStartHook, runPostToolUseHook, runStopHook } = await import(
const { installHookPack, uninstallHookPack, runSessionStartHook, runPreToolUseHook, runPostToolUseHook, runStopHook } = await import(
"../hooks/pack.ts"
);

Expand Down Expand Up @@ -301,14 +301,17 @@ export async function handleHooksCommand({ positional, flags }: ParsedArgs): Pro
case "session-start":
await runSessionStartHook();
return 0;
case "pre-tool-use":
runPreToolUseHook();
return 0;
case "post-tool-use":
runPostToolUseHook();
return 0;
case "stop":
runStopHook();
return 0;
default:
return usageError(`unknown hooks subcommand '${sub}' (expected install | uninstall | session-start | post-tool-use | stop)`);
return usageError(`unknown hooks subcommand '${sub}' (expected install | uninstall | session-start | pre-tool-use | post-tool-use | stop)`);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export const ALL_MEMORY_TOOLS = [
"muse_tech_debt",
"muse_health",
"muse_episodes",
"muse_preflight",
"muse_playbook_recall",
"muse_playbook_record",
"muse_sync_broadcast",
Expand Down
56 changes: 53 additions & 3 deletions src/hooks/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { openStore, type Store } from "../store.ts";
import { parseCurrentFile } from "../governor.ts";
import { rankAndRetrieveMemories } from "../retrieval/ranking.ts";
import { recordObservation, listObservations } from "../learning/observation.ts";
import { preflightContext } from "./preflight.ts";
import { distillObservationsToCandidates } from "../learning/distill.ts";
import { recordAuditEvent } from "../audit.ts";

Expand All @@ -28,7 +29,7 @@ import { recordAuditEvent } from "../audit.ts";
* harness-independent by default — see {@link resolveHookSettingsPath}.
*/

export type MuseHookEvent = "SessionStart" | "PostToolUse" | "Stop";
export type MuseHookEvent = "SessionStart" | "PreToolUse" | "PostToolUse" | "Stop";

export interface HookPackReport {
event: MuseHookEvent;
Expand All @@ -39,22 +40,27 @@ export interface HookPackReport {

const HOOK_COMMANDS: Record<MuseHookEvent, string> = {
SessionStart: "memory hooks session-start",
PreToolUse: "memory hooks pre-tool-use",
PostToolUse: "memory hooks post-tool-use",
Stop: "memory hooks stop",
};

const HOOK_EVENTS: MuseHookEvent[] = ["SessionStart", "PostToolUse", "Stop"];
const HOOK_EVENTS: MuseHookEvent[] = ["SessionStart", "PreToolUse", "PostToolUse", "Stop"];

/** Timeout so a hung memory command can never stall the agent loop. */
const HOOK_TIMEOUT_SECONDS = 10;

/** Builds the Claude Code hook entry for one event. */
export function buildHookEntry(event: MuseHookEvent): Record<string, any> {
return {
const entry: Record<string, any> = {
type: "command",
command: HOOK_COMMANDS[event],
timeout: HOOK_TIMEOUT_SECONDS,
};
// Claude Code's PreToolUse entries are matcher-scoped; pre-flight only makes
// sense when a file is about to be edited.
if (event === "PreToolUse") entry.matcher = "Edit|Write|MultiEdit|NotebookEdit";
return entry;
}

export interface HookPackOptions {
Expand Down Expand Up @@ -215,6 +221,7 @@ interface HookStdinPayload {
cwd?: string;
tool_name?: string;
tool_response?: string;
tool_input?: Record<string, any>;
}

function readStdinJson(): HookStdinPayload {
Expand Down Expand Up @@ -297,6 +304,49 @@ export async function runSessionStartHook(): Promise<void> {
}
}

/**
* PreToolUse: read-only speculative pre-flight for the file about to be edited.
* Emits anchored invariants + a risk line as additional context; never writes.
*/
export function runPreToolUseHook(): void {
try {
const payload = readStdinJson();
const ctx = resolveHookContext(payload.cwd);
if (!ctx) return;
const input = payload.tool_input ?? {};
const rawPath = input.file_path ?? input.filePath ?? input.path;
const files = typeof rawPath === "string" ? [rawPath] : undefined;
const result = preflightContext(ctx.store, {
files,
project: ctx.project,
cwd: payload.cwd,
});

const lines: string[] = [
"<muse-memory>",
`Pre-flight: risk ${result.risk.level} (${result.risk.score}/100) · ${result.files.length} file(s)`,
];
if (result.anchors.length > 0) {
lines.push("", "## Anchored invariants");
for (const anchor of result.anchors.slice(0, 5)) {
lines.push(`- [${anchor.entry.type}] ${sanitizeHookContextBlock(anchor.entry.title)}: ${sanitizeHookContextBlock(anchor.entry.content.slice(0, 160))}`);
}
}
if (result.context.markdown.trim()) lines.push("", sanitizeHookContextBlock(result.context.markdown));
lines.push("</muse-memory>", "");

const output = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: lines.join("\n"),
},
};
process.stdout.write(JSON.stringify(output));
} catch (err) {
failOpen("pre-tool-use", err);
}
}

/** PostToolUse: capture the tool result as an ephemeral, Vibeguard-scanned observation (including tool_name and session_id metadata). */
export function runPostToolUseHook(): void {
try {
Expand Down
46 changes: 46 additions & 0 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
runDistillRouter,
} from "./cognition/index.ts";
import { episodesFromObservations } from "./cognition/episodes.ts";
import { preflightContext } from "./hooks/preflight.ts";
import {
formatPlaybook,
getPlaybook,
Expand Down Expand Up @@ -1671,6 +1672,21 @@ You are equipped with Muse Memory, an autonomous persistent cognitive memory sys
},
},
},
{
name: "muse_preflight",
description: "Read-only pre-flight: anchored invariants, blast-radius risk and a token-budgeted context pack for the files about to be edited",
inputSchema: {
type: "object",
properties: {
files: { type: "array", items: { type: "string" }, description: "Files about to be edited" },
symbols: { type: "array", items: { type: "string" }, description: "Symbols about to change" },
branch: { type: "string", description: "Current branch name" },
project: { type: "string", description: "Optional project scope" },
budget: { type: "number", description: "Entry token budget (default 800)" },
dir: { type: "string", description: "Optional workspace directory path" },
},
},
},
{
name: "muse_episodes",
description: "Recall recent episodic narratives (goal → hypothesis → trajectory → verification → retrospective) built from observations",
Expand Down Expand Up @@ -3134,6 +3150,36 @@ You are equipped with Muse Memory, an autonomous persistent cognitive memory sys
});
return toolResult(report);
}
case "muse_preflight": {
const files = Array.isArray(a.files) ? a.files.map(String) : undefined;
const symbols = Array.isArray(a.symbols) ? a.symbols.map(String) : undefined;
const budget = a.budget !== undefined ? clampLimit(a.budget, 800, 8000, 100) : undefined;
try {
const result = preflightContext(activeStore, {
files,
symbols,
branch: a.branch ? String(a.branch) : undefined,
project: a.project ? String(a.project) : undefined,
tokenBudget: budget,
cwd: activeRoot,
});
return toolResult({
query: result.query,
files: result.files,
risk: result.risk,
anchors: result.anchors.map((anchor) => ({
id: anchor.entry.id,
title: anchor.entry.title,
type: anchor.entry.type,
score: anchor.score,
why: anchor.why_retrieved,
})),
markdown: result.context.markdown,
});
} catch (err) {
return toolError(err instanceof Error ? err.message : String(err));
}
}
case "muse_episodes": {
const episodeDir = String(activeStore.memoryDir || activeRoot || process.cwd());
const limit = a.limit !== undefined ? clampLimit(a.limit, 5, 50, 1) : 5;
Expand Down
11 changes: 7 additions & 4 deletions test/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,25 @@ describe("Claude Code Hook Pack (GAP-2)", () => {
expect(entry.timeout).toBeGreaterThan(0);
});

it("installs all three hooks into settings.json", () => {
it("installs all four hooks into settings.json", () => {
const settingsDir = join(tmpDir, ".claude");
mkdirSync(settingsDir, { recursive: true });
const settingsPath = join(settingsDir, "settings.json");

const result = installHookPack(tmpDir, { settingsPath });

expect(result.installed).toHaveLength(3);
expect(result.installed).toHaveLength(4);
expect(result.installed.map((r) => r.event)).toEqual([
"SessionStart",
"PreToolUse",
"PostToolUse",
"Stop",
]);

const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
expect(settings.hooks.SessionStart[0].command).toBe("memory hooks session-start");
expect(settings.hooks.PreToolUse[0].command).toBe("memory hooks pre-tool-use");
expect(settings.hooks.PreToolUse[0].matcher).toContain("Edit");
expect(settings.hooks.PostToolUse[0].command).toBe("memory hooks post-tool-use");
expect(settings.hooks.Stop[0].command).toBe("memory hooks stop");
});
Expand Down Expand Up @@ -87,7 +90,7 @@ describe("Claude Code Hook Pack (GAP-2)", () => {
writeFileSync(settingsPath, JSON.stringify(settings), "utf8");

const result = uninstallHookPack(tmpDir, { settingsPath });
expect(result.removed).toEqual(["SessionStart", "PostToolUse", "Stop"]);
expect(result.removed).toEqual(["SessionStart", "PreToolUse", "PostToolUse", "Stop"]);

const after = JSON.parse(readFileSync(settingsPath, "utf8"));
expect(after.hooks.Stop).toHaveLength(1);
Expand Down Expand Up @@ -186,7 +189,7 @@ describe("Claude Code Hook Pack (GAP-2)", () => {
installHookPack(tmpDir);
const result = uninstallHookPack(tmpDir);
expect(result.settingsPath).toBe(join(tmpDir, ".agents", "hooks.json"));
expect(result.removed).toEqual(["SessionStart", "PostToolUse", "Stop"]);
expect(result.removed).toEqual(["SessionStart", "PreToolUse", "PostToolUse", "Stop"]);
});
});
});
Expand Down
41 changes: 41 additions & 0 deletions test/preflight_hooks_mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, test, expect } from "bun:test";
import { mkdirSync, 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 { createServer } from "../src/mcp.ts";
import { buildHookEntry } from "../src/hooks/pack.ts";

describe("W26-3: pre-flight hook and MCP surface", () => {
test("PreToolUse hook entry is matcher-scoped and invokes the hook command", () => {
const entry = buildHookEntry("PreToolUse");
expect(entry.command).toBe("memory hooks pre-tool-use");
expect(entry.matcher).toContain("Edit");
});

test("muse_preflight returns risk and anchored invariants read-only", async () => {
const root = mkdtempSync(join(tmpdir(), "muse-preflight-mcp-"));
const memoryDir = join(root, ".memory");
mkdirSync(memoryDir, { recursive: true });
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 before = store.db!.query<{ access_count: number }>("SELECT access_count FROM memories WHERE id = ?").get([constraint.id])?.access_count ?? 0;
try {
const server = createServer(root, "full");
const call = (server as any)._requestHandlers.get("tools/call");
const result = await call({
method: "tools/call",
params: { name: "muse_preflight", arguments: { dir: root, files: ["src/auth.ts"], project: "p" } },
});
expect(result.isError).toBeFalsy();
const payload = JSON.parse(result.content[0].text);
expect(payload.risk.level).toBeDefined();
expect(payload.anchors.map((anchor: any) => anchor.id)).toContain(constraint.id);
const after = store.db!.query<{ access_count: number }>("SELECT access_count FROM memories WHERE id = ?").get([constraint.id])?.access_count ?? 0;
expect(after).toBe(before);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
Loading