diff --git a/.agents/artifacts/TECHNICAL_DEBT-wave26.md b/.agents/artifacts/TECHNICAL_DEBT-wave26.md index 7b18664..457933b 100644 --- a/.agents/artifacts/TECHNICAL_DEBT-wave26.md +++ b/.agents/artifacts/TECHNICAL_DEBT-wave26.md @@ -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 | diff --git a/.agents/context/current.md b/.agents/context/current.md index 245e25b..823d8ad 100644 --- a/.agents/context/current.md +++ b/.agents/context/current.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index d0e89a9..ca3c1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/claims.json b/claims.json index d7384c4..4d2c07c 100644 --- a/claims.json +++ b/claims.json @@ -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, @@ -14,8 +14,8 @@ "networkMillis": 300 }, "tests": { - "count": 1495, - "assertions": 7172, - "files": 231 + "count": 1497, + "assertions": 7177, + "files": 232 } } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 1faaa5d..04639eb 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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. @@ -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 | @@ -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. diff --git a/docs/mcp-tool-matrix.json b/docs/mcp-tool-matrix.json index 30da352..431695f 100644 --- a/docs/mcp-tool-matrix.json +++ b/docs/mcp-tool-matrix.json @@ -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", @@ -110,6 +110,7 @@ "muse_playbook_recall", "muse_playbook_record", "muse_pr_context", + "muse_preflight", "muse_profile_list", "muse_reconcile_anchors", "muse_sync_broadcast", diff --git a/src/cli/retrieval.ts b/src/cli/retrieval.ts index 82bbbca..03b3842 100644 --- a/src/cli/retrieval.ts +++ b/src/cli/retrieval.ts @@ -263,7 +263,7 @@ export async function handleHookCommand({ positional, flags }: ParsedArgs): Prom */ export async function handleHooksCommand({ positional, flags }: ParsedArgs): Promise { 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" ); @@ -301,6 +301,9 @@ 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; @@ -308,7 +311,7 @@ export async function handleHooksCommand({ positional, flags }: ParsedArgs): Pro 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)`); } } diff --git a/src/connect.ts b/src/connect.ts index e7083ec..51ed357 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -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", diff --git a/src/hooks/pack.ts b/src/hooks/pack.ts index a99d693..ad4f76c 100644 --- a/src/hooks/pack.ts +++ b/src/hooks/pack.ts @@ -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"; @@ -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; @@ -39,22 +40,27 @@ export interface HookPackReport { const HOOK_COMMANDS: Record = { 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 { - return { + const entry: Record = { 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 { @@ -215,6 +221,7 @@ interface HookStdinPayload { cwd?: string; tool_name?: string; tool_response?: string; + tool_input?: Record; } function readStdinJson(): HookStdinPayload { @@ -297,6 +304,49 @@ export async function runSessionStartHook(): Promise { } } +/** + * 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[] = [ + "", + `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("", ""); + + 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 { diff --git a/src/mcp.ts b/src/mcp.ts index ee00ea4..ad1c4e9 100644 --- a/src/mcp.ts +++ b/src/mcp.ts @@ -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, @@ -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", @@ -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; diff --git a/test/hooks.test.ts b/test/hooks.test.ts index 4644f58..3eda838 100644 --- a/test/hooks.test.ts +++ b/test/hooks.test.ts @@ -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"); }); @@ -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); @@ -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"]); }); }); }); diff --git a/test/preflight_hooks_mcp.test.ts b/test/preflight_hooks_mcp.test.ts new file mode 100644 index 0000000..f97cea9 --- /dev/null +++ b/test/preflight_hooks_mcp.test.ts @@ -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 }); + } + }); +});