mason-context
Advanced tools
| #!/usr/bin/env node | ||
| // src/hook/hook.ts | ||
| import fs5 from "fs/promises"; | ||
| import path8 from "path"; | ||
| import os from "os"; | ||
| // src/decisions/decisions.ts | ||
| import fs3 from "fs/promises"; | ||
| import path5 from "path"; | ||
| import { createHash } from "crypto"; | ||
| // src/snapshot/snapshot.ts | ||
| import fs2 from "fs/promises"; | ||
| import path3 from "path"; | ||
| import { execFile as execFile2 } from "child_process"; | ||
| import { promisify as promisify2 } from "util"; | ||
| import fg3 from "fast-glob"; | ||
| // src/mcp/sampler.ts | ||
| import fs from "fs/promises"; | ||
| import path from "path"; | ||
| import { execFile } from "child_process"; | ||
| import { promisify } from "util"; | ||
| import fg from "fast-glob"; | ||
| var exec = promisify(execFile); | ||
| // src/test-map.ts | ||
| import path2 from "path"; | ||
| import fg2 from "fast-glob"; | ||
| // src/snapshot/snapshot.ts | ||
| var exec2 = promisify2(execFile2); | ||
| async function getCurrentGitHash(rootDir) { | ||
| try { | ||
| const { stdout } = await exec2("git", ["rev-parse", "HEAD"], { | ||
| cwd: rootDir | ||
| }); | ||
| return stdout.trim(); | ||
| } catch { | ||
| return "unknown"; | ||
| } | ||
| } | ||
| // src/context/lexical.ts | ||
| import path4 from "path"; | ||
| // src/decisions/decisions.ts | ||
| function decisionsDir(rootDir) { | ||
| return path5.join(rootDir, ".mason", "decisions"); | ||
| } | ||
| async function loadDecisions(rootDir) { | ||
| let entries; | ||
| try { | ||
| entries = await fs3.readdir(decisionsDir(rootDir)); | ||
| } catch { | ||
| return []; | ||
| } | ||
| const records = []; | ||
| for (const entry of entries) { | ||
| if (!entry.endsWith(".json")) continue; | ||
| try { | ||
| const raw = await fs3.readFile( | ||
| path5.join(decisionsDir(rootDir), entry), | ||
| "utf-8" | ||
| ); | ||
| const parsed = JSON.parse(raw); | ||
| if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) { | ||
| continue; | ||
| } | ||
| records.push(parsed); | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| return records.sort((a, b) => a.id.localeCompare(b.id)); | ||
| } | ||
| // src/decisions/drift.ts | ||
| import path7 from "path"; | ||
| // src/drift/drift.ts | ||
| import fs4 from "fs/promises"; | ||
| import path6 from "path"; | ||
| import { execFile as execFile3 } from "child_process"; | ||
| import { promisify as promisify3 } from "util"; | ||
| var exec3 = promisify3(execFile3); | ||
| async function getChangesWithStatus(resolvedRoot, fromHash) { | ||
| if (!fromHash || fromHash === "unknown") return null; | ||
| try { | ||
| const { stdout } = await exec3( | ||
| "git", | ||
| ["diff", "--name-status", "-M", fromHash, "HEAD"], | ||
| { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 } | ||
| ); | ||
| const changes = []; | ||
| for (const line of stdout.split("\n")) { | ||
| if (!line.trim()) continue; | ||
| const parts = line.split(" "); | ||
| if (parts.some((p) => p.startsWith(".mason/"))) continue; | ||
| const code = parts[0]; | ||
| if (code.startsWith("R") && parts.length >= 3) { | ||
| changes.push({ | ||
| status: "renamed", | ||
| path: parts[2], | ||
| previousPath: parts[1] | ||
| }); | ||
| } else if (code.startsWith("C") && parts.length >= 3) { | ||
| changes.push({ status: "added", path: parts[2] }); | ||
| } else if (code === "A" && parts.length >= 2) { | ||
| changes.push({ status: "added", path: parts[1] }); | ||
| } else if (code === "D" && parts.length >= 2) { | ||
| changes.push({ status: "deleted", path: parts[1] }); | ||
| } else if (parts.length >= 2) { | ||
| changes.push({ status: "modified", path: parts[1] }); | ||
| } | ||
| } | ||
| return changes; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| // src/decisions/drift.ts | ||
| async function computeDecisionDrift(rootDir, decisions) { | ||
| const resolvedRoot = path7.resolve(rootDir); | ||
| const records = decisions ?? await loadDecisions(resolvedRoot); | ||
| const report = { | ||
| historyAvailable: true, | ||
| totalDecisions: records.length, | ||
| staleDecisions: {} | ||
| }; | ||
| const head = await getCurrentGitHash(resolvedRoot); | ||
| const changesByHash = /* @__PURE__ */ new Map(); | ||
| for (const record of records) { | ||
| if (record.status !== "active" || record.files.length === 0) continue; | ||
| if (record.refreshedHash === head) continue; | ||
| let touched = changesByHash.get(record.refreshedHash); | ||
| if (touched === void 0) { | ||
| const changes = await getChangesWithStatus( | ||
| resolvedRoot, | ||
| record.refreshedHash | ||
| ); | ||
| if (changes === null) { | ||
| touched = null; | ||
| } else { | ||
| touched = /* @__PURE__ */ new Set(); | ||
| for (const change of changes) { | ||
| touched.add(change.path); | ||
| if (change.previousPath) touched.add(change.previousPath); | ||
| } | ||
| } | ||
| changesByHash.set(record.refreshedHash, touched); | ||
| } | ||
| if (touched === null) { | ||
| report.historyAvailable = false; | ||
| continue; | ||
| } | ||
| const hits = record.files.filter((f) => touched.has(f)); | ||
| if (hits.length > 0) { | ||
| report.staleDecisions[record.id] = hits; | ||
| } | ||
| } | ||
| return report; | ||
| } | ||
| // src/hook/hook.ts | ||
| var MAX_INJECTED_DECISIONS = 3; | ||
| var MAX_WALK_UP = 30; | ||
| var SUPPORTED_TOOLS = /* @__PURE__ */ new Set(["Read", "Edit", "Write"]); | ||
| async function exists(p) { | ||
| try { | ||
| await fs5.access(p); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| async function findMasonRoot(startDir) { | ||
| let dir = startDir; | ||
| for (let i = 0; i < MAX_WALK_UP; i++) { | ||
| if (await exists(path8.join(dir, ".mason", "decisions"))) return dir; | ||
| if (await exists(path8.join(dir, ".git"))) return null; | ||
| const parent = path8.dirname(dir); | ||
| if (parent === dir) return null; | ||
| dir = parent; | ||
| } | ||
| return null; | ||
| } | ||
| function anchorsCover(record, relPath) { | ||
| return record.files.some((anchor) => { | ||
| const a = anchor.replace(/\/+$/, ""); | ||
| return a === relPath || relPath.startsWith(`${a}/`); | ||
| }); | ||
| } | ||
| function exactAnchor(record, relPath) { | ||
| return record.files.some((a) => a.replace(/\/+$/, "") === relPath); | ||
| } | ||
| function stateKey(input) { | ||
| const raw = `${input.session_id ?? "nosession"}${input.agent_id ? `-${input.agent_id}` : ""}`; | ||
| return raw.replace(/[^A-Za-z0-9_-]/g, "").slice(0, 120) || "nosession"; | ||
| } | ||
| async function loadInjected(stateFile) { | ||
| try { | ||
| const parsed = JSON.parse(await fs5.readFile(stateFile, "utf-8")); | ||
| return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []); | ||
| } catch { | ||
| return /* @__PURE__ */ new Set(); | ||
| } | ||
| } | ||
| function formatContext(relPath, records, staleIds) { | ||
| const lines = []; | ||
| lines.push( | ||
| `Mason: recorded team knowledge anchored to ${relPath} \u2014 treat as constraints. Do not modify decision records in .mason/decisions/.` | ||
| ); | ||
| for (const record of records) { | ||
| const stale = staleIds.has(record.id) ? " [recorded against an older commit \u2013 verify against current code before relying on it]" : ""; | ||
| lines.push( | ||
| `- [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(", ")})${stale}` | ||
| ); | ||
| } | ||
| return lines.join("\n"); | ||
| } | ||
| async function runHook(stdinText, env = {}) { | ||
| let input; | ||
| try { | ||
| input = JSON.parse(stdinText); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null; | ||
| const filePath = input.tool_input?.file_path; | ||
| if (!filePath || typeof filePath !== "string") return null; | ||
| const absPath = path8.isAbsolute(filePath) ? filePath : path8.resolve(input.cwd ?? process.cwd(), filePath); | ||
| const root = await findMasonRoot(path8.dirname(absPath)); | ||
| if (!root) return null; | ||
| const relPath = path8.relative(root, absPath).split(path8.sep).join("/"); | ||
| if (relPath.startsWith("..")) return null; | ||
| const records = await loadDecisions(root); | ||
| const matched = records.filter( | ||
| (r) => r.status === "active" && anchorsCover(r, relPath) | ||
| ); | ||
| if (matched.length === 0) return null; | ||
| const stateDir = env.stateDir ?? os.tmpdir(); | ||
| const stateFile = path8.join(stateDir, `mason-hook-${stateKey(input)}.json`); | ||
| const injected = await loadInjected(stateFile); | ||
| const fresh = matched.filter((r) => !injected.has(r.id)); | ||
| if (fresh.length === 0) return null; | ||
| fresh.sort((a, b) => { | ||
| const exactDiff = Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath)); | ||
| if (exactDiff !== 0) return exactDiff; | ||
| return b.updatedAt.localeCompare(a.updatedAt); | ||
| }); | ||
| const selected = fresh.slice(0, MAX_INJECTED_DECISIONS); | ||
| const drift = await computeDecisionDrift(root, selected); | ||
| const staleIds = new Set(Object.keys(drift.staleDecisions)); | ||
| for (const record of selected) injected.add(record.id); | ||
| try { | ||
| await fs5.writeFile(stateFile, JSON.stringify([...injected]), "utf-8"); | ||
| } catch { | ||
| } | ||
| return JSON.stringify({ | ||
| hookSpecificOutput: { | ||
| hookEventName: "PostToolUse", | ||
| additionalContext: formatContext(relPath, selected, staleIds) | ||
| } | ||
| }); | ||
| } | ||
| // src/hook/cli.ts | ||
| var USAGE = `Usage: mason-hook [--print-config | --help] | ||
| Claude Code PostToolUse hook: when the session reads or edits a file that a | ||
| Mason decision record anchors, the record is injected into the model's | ||
| context. Deterministic lookup, no LLM call; silent when nothing matches. | ||
| Reads the hook JSON on stdin and prints the hook output JSON on stdout. | ||
| Register it via .claude/settings.json (committed to the repo, so the whole | ||
| team gets the same rail): | ||
| mason-hook --print-config Print the settings.json hooks block | ||
| Repeat injections are deduped per session; state lives in the OS temp dir.`; | ||
| var SETTINGS_CONFIG = { | ||
| hooks: { | ||
| PostToolUse: [ | ||
| { | ||
| matcher: "Read|Edit|Write", | ||
| hooks: [ | ||
| { | ||
| type: "command", | ||
| command: "npx -y -p mason-context mason-hook", | ||
| timeout: 10 | ||
| } | ||
| ] | ||
| } | ||
| ] | ||
| } | ||
| }; | ||
| async function runHookCli(argv, stdinText, io = { | ||
| out: (line) => process.stdout.write(`${line} | ||
| `), | ||
| err: (line) => process.stderr.write(`${line} | ||
| `) | ||
| }, env = {}) { | ||
| if (argv.includes("--help") || argv.includes("-h")) { | ||
| io.out(USAGE); | ||
| return 0; | ||
| } | ||
| if (argv.includes("--print-config")) { | ||
| io.out(JSON.stringify(SETTINGS_CONFIG, null, 2)); | ||
| return 0; | ||
| } | ||
| try { | ||
| const output = await runHook(stdinText, env); | ||
| if (output !== null) io.out(output); | ||
| } catch { | ||
| } | ||
| return 0; | ||
| } | ||
| // bin/mason-hook.ts | ||
| async function readStdin() { | ||
| if (process.stdin.isTTY) return ""; | ||
| const chunks = []; | ||
| for await (const chunk of process.stdin) { | ||
| chunks.push(Buffer.from(chunk)); | ||
| } | ||
| return Buffer.concat(chunks).toString("utf-8"); | ||
| } | ||
| readStdin().then((stdinText) => runHookCli(process.argv.slice(2), stdinText)).then((code) => process.exit(code)).catch(() => process.exit(0)); | ||
| //# sourceMappingURL=mason-hook.js.map |
Sorry, the diff of this file is too big to display
+3
-2
| { | ||
| "name": "mason-context", | ||
| "version": "0.7.0", | ||
| "version": "0.8.0", | ||
| "description": "MCP server for codebase context engineering — feature-to-file concept maps, change impact, and Confluence wiki sync for AI coding assistants", | ||
@@ -12,3 +12,4 @@ "type": "module", | ||
| "mason-drift": "dist/mason-drift.js", | ||
| "mason-audit": "dist/mason-audit.js" | ||
| "mason-audit": "dist/mason-audit.js", | ||
| "mason-hook": "dist/mason-hook.js" | ||
| }, | ||
@@ -15,0 +16,0 @@ "scripts": { |
+13
-1
@@ -226,4 +226,16 @@ # Mason – the system of record for your codebase's AI assistants 👷 | ||
| Omit `agent-command` for detect-only mode: no agent, no credentials — the job fails when the context files have drifted, which is a reasonable default for repos that want the signal before the automation. Note: PRs created with the default `GITHUB_TOKEN` don't trigger the repo's own CI; run your agent with PAT-backed auth if you need that. | ||
| Omit `agent-command` for detect-only mode: no agent, no credentials — the job fails when the context files have drifted, which is a reasonable default for repos that want the signal before the automation. Two GitHub notes: the repo setting **"Allow GitHub Actions to create and approve pull requests"** (Settings → Actions → General) must be enabled for the PR step, and PRs created with the default `GITHUB_TOKEN` don't trigger the repo's own CI — run your agent with PAT-backed auth if you need that. | ||
| ## Decision injection (mason-hook) | ||
| Recorded knowledge only helps if it shows up. Retrieval tools depend on the model deciding to call them — and it often doesn't. `mason-hook` removes the gamble: it's a Claude Code `PostToolUse` hook that fires when a session reads or edits a file, looks up the decision records anchored to that file (exact path or directory prefix), and injects them into the model's context. Deterministic lookup, no LLM call, ~100ms, silent when nothing matches. Each decision is injected at most once per session, and records whose anchors drifted since verification carry a verify-before-relying marker. | ||
| ```bash | ||
| npx -p mason-context mason-hook --print-config # the settings block to add | ||
| ``` | ||
| Add the printed block to `.claude/settings.json` — the *committed* project settings, so every teammate's sessions get the same rail. The loop this closes: someone records a constraint once with `save_decision` ("this screen has a v1 and v2 — new work goes in v2 behind flag X"), and from then on any session that touches those files gets told, whether or not it thought to ask. | ||
| For faster fires than `npx` resolution allows, install the package (`npm i -D mason-context`) and point the command at `node_modules/.bin/mason-hook`. | ||
| ## Confluence sync | ||
@@ -230,0 +242,0 @@ |
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
841958
10.51%13
18.18%6417
5.13%346
3.59%37
15.63%22
15.79%