mason-context 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/hook/hook.ts","../src/decisions/decisions.ts","../src/snapshot/snapshot.ts","../src/mcp/sampler.ts","../src/test-map.ts","../src/context/lexical.ts","../src/decisions/drift.ts","../src/drift/drift.ts","../src/hook/cli.ts","../bin/mason-hook.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { loadDecisions } from \"../decisions/decisions.js\";\nimport type { DecisionRecord } from \"../decisions/decisions.js\";\nimport { computeDecisionDrift } from \"../decisions/drift.js\";\n\n/** More than a few constraints per fire is noise, not context. */\nconst MAX_INJECTED_DECISIONS = 3;\n/** Walk-up bound when locating the Mason root from an edited file. */\nconst MAX_WALK_UP = 30;\n\nconst SUPPORTED_TOOLS = new Set([\"Read\", \"Edit\", \"Write\"]);\n\nexport interface HookStdin {\n session_id?: string;\n agent_id?: string;\n cwd?: string;\n hook_event_name?: string;\n tool_name?: string;\n tool_input?: { file_path?: string };\n}\n\nexport interface HookEnv {\n /** Override for tests; defaults to os.tmpdir(). */\n stateDir?: string;\n}\n\nasync function exists(p: string): Promise<boolean> {\n try {\n await fs.access(p);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Locate the nearest ancestor holding a .mason/decisions store. Stops at the\n * first .git boundary — a repo without Mason must stay silent, not borrow a\n * parent directory's decisions.\n */\nasync function findMasonRoot(startDir: string): Promise<string | null> {\n let dir = startDir;\n for (let i = 0; i < MAX_WALK_UP; i++) {\n if (await exists(path.join(dir, \".mason\", \"decisions\"))) return dir;\n if (await exists(path.join(dir, \".git\"))) return null;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n return null;\n}\n\nfunction anchorsCover(record: DecisionRecord, relPath: string): boolean {\n return record.files.some((anchor) => {\n const a = anchor.replace(/\\/+$/, \"\");\n return a === relPath || relPath.startsWith(`${a}/`);\n });\n}\n\nfunction exactAnchor(record: DecisionRecord, relPath: string): boolean {\n return record.files.some((a) => a.replace(/\\/+$/, \"\") === relPath);\n}\n\nfunction stateKey(input: HookStdin): string {\n const raw = `${input.session_id ?? \"nosession\"}${input.agent_id ? `-${input.agent_id}` : \"\"}`;\n return raw.replace(/[^A-Za-z0-9_-]/g, \"\").slice(0, 120) || \"nosession\";\n}\n\nasync function loadInjected(stateFile: string): Promise<Set<string>> {\n try {\n const parsed = JSON.parse(await fs.readFile(stateFile, \"utf-8\"));\n return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\n\nfunction formatContext(\n relPath: string,\n records: DecisionRecord[],\n staleIds: Set<string>\n): string {\n const lines: string[] = [];\n lines.push(\n `Mason: recorded team knowledge anchored to ${relPath} — treat as constraints. Do not modify decision records in .mason/decisions/.`\n );\n for (const record of records) {\n const stale = staleIds.has(record.id)\n ? \" [recorded against an older commit – verify against current code before relying on it]\"\n : \"\";\n lines.push(\n `- [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(\", \")})${stale}`\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * The push half of Mason's memory: when an agent touches a file that a\n * decision record anchors, the record is injected into the session via the\n * PostToolUse hook contract — deterministically, without relying on the\n * model deciding to ask. Returns the hook's stdout JSON, or null for\n * \"stay silent\" (no store, no match, already injected, malformed input —\n * a hook must never disrupt the session).\n */\nexport async function runHook(\n stdinText: string,\n env: HookEnv = {}\n): Promise<string | null> {\n let input: HookStdin;\n try {\n input = JSON.parse(stdinText);\n } catch {\n return null;\n }\n\n if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;\n const filePath = input.tool_input?.file_path;\n if (!filePath || typeof filePath !== \"string\") return null;\n\n const absPath = path.isAbsolute(filePath)\n ? filePath\n : path.resolve(input.cwd ?? process.cwd(), filePath);\n const root = await findMasonRoot(path.dirname(absPath));\n if (!root) return null;\n const relPath = path.relative(root, absPath).split(path.sep).join(\"/\");\n if (relPath.startsWith(\"..\")) return null;\n\n const records = await loadDecisions(root);\n const matched = records.filter(\n (r) => r.status === \"active\" && anchorsCover(r, relPath)\n );\n if (matched.length === 0) return null;\n\n const stateDir = env.stateDir ?? os.tmpdir();\n const stateFile = path.join(stateDir, `mason-hook-${stateKey(input)}.json`);\n const injected = await loadInjected(stateFile);\n const fresh = matched.filter((r) => !injected.has(r.id));\n if (fresh.length === 0) return null;\n\n // Exact-file anchors outrank directory-prefix ones; newest knowledge wins ties.\n fresh.sort((a, b) => {\n const exactDiff =\n Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath));\n if (exactDiff !== 0) return exactDiff;\n return b.updatedAt.localeCompare(a.updatedAt);\n });\n const selected = fresh.slice(0, MAX_INJECTED_DECISIONS);\n\n const drift = await computeDecisionDrift(root, selected);\n const staleIds = new Set(Object.keys(drift.staleDecisions));\n\n for (const record of selected) injected.add(record.id);\n try {\n await fs.writeFile(stateFile, JSON.stringify([...injected]), \"utf-8\");\n } catch {\n // Dedupe state is a convenience; losing it must not block injection.\n }\n\n return JSON.stringify({\n hookSpecificOutput: {\n hookEventName: \"PostToolUse\",\n additionalContext: formatContext(relPath, selected, staleIds),\n },\n });\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { createHash } from \"node:crypto\";\nimport { getCurrentGitHash } from \"../snapshot/snapshot.js\";\nimport { jaccard, tokenSet } from \"../context/lexical.js\";\n\nexport type DecisionCategory =\n | \"decision\"\n | \"gotcha\"\n | \"deprecation\"\n | \"convention\";\nexport type DecisionStatus = \"active\" | \"superseded\";\n\n/**\n * One unit of team knowledge the code alone can't express: a failed\n * approach, a deprecation, a workaround's reason, a review-settled\n * convention. Stored one file per record under .mason/decisions/ so\n * concurrent additions on different branches merge without conflict,\n * while concurrent edits to the SAME record conflict — contested\n * knowledge should reach a human.\n */\nexport interface DecisionRecord {\n version: 1;\n id: string;\n title: string;\n body: string;\n category: DecisionCategory;\n /** Repo-relative anchor files. Empty means pure prose — never goes stale. */\n files: string[];\n createdAt: string;\n updatedAt: string;\n /** Commit this record was last verified against. */\n refreshedHash: string;\n status: DecisionStatus;\n supersededBy?: string;\n}\n\nexport const TITLE_MAX_CHARS = 80;\nexport const BODY_MAX_CHARS = 1500;\nexport const MAX_ACTIVE_DECISIONS = 150;\n\nconst DUPLICATE_JACCARD = 0.5;\nconst DUPLICATE_JACCARD_WITH_SHARED_FILE = 0.35;\n\nfunction decisionsDir(rootDir: string): string {\n return path.join(rootDir, \".mason\", \"decisions\");\n}\n\nexport async function loadDecisions(\n rootDir: string\n): Promise<DecisionRecord[]> {\n let entries: string[];\n try {\n entries = await fs.readdir(decisionsDir(rootDir));\n } catch {\n return [];\n }\n const records: DecisionRecord[] = [];\n for (const entry of entries) {\n if (!entry.endsWith(\".json\")) continue;\n try {\n const raw = await fs.readFile(\n path.join(decisionsDir(rootDir), entry),\n \"utf-8\"\n );\n const parsed = JSON.parse(raw);\n // Skip unknown versions and malformed records individually — one bad\n // merge artifact must not take down the store.\n if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {\n continue;\n }\n records.push(parsed);\n } catch {\n continue;\n }\n }\n return records.sort((a, b) => a.id.localeCompare(b.id));\n}\n\nexport async function saveDecisionRecord(\n rootDir: string,\n record: DecisionRecord\n): Promise<void> {\n await fs.mkdir(decisionsDir(rootDir), { recursive: true });\n await fs.writeFile(\n path.join(decisionsDir(rootDir), `${record.id}.json`),\n JSON.stringify(record, null, 2) + \"\\n\",\n \"utf-8\"\n );\n}\n\n/**\n * Deterministic, human-readable id: kebab slug of the title, ≤60 chars.\n * A slug collision with a DIFFERENT record appends a 6-hex content suffix.\n */\nexport function decisionIdFor(\n title: string,\n body: string,\n existingIds: Set<string>\n): string {\n const slug = title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n .slice(0, 60)\n .replace(/-+$/, \"\");\n if (!existingIds.has(slug)) return slug || \"decision\";\n const suffix = createHash(\"sha1\")\n .update(title + body)\n .digest(\"hex\")\n .slice(0, 6);\n return `${slug}-${suffix}`;\n}\n\nexport function findNearDuplicate(\n candidate: { title: string; body: string; files: string[] },\n existing: DecisionRecord[]\n): { record: DecisionRecord; similarity: number } | null {\n const candidateTokens = tokenSet(`${candidate.title} ${candidate.body}`);\n const candidateFiles = new Set(candidate.files);\n let best: { record: DecisionRecord; similarity: number } | null = null;\n\n for (const record of existing) {\n if (record.status !== \"active\") continue;\n const similarity = jaccard(\n candidateTokens,\n tokenSet(`${record.title} ${record.body}`)\n );\n const sharesFile = record.files.some((f) => candidateFiles.has(f));\n const threshold = sharesFile\n ? DUPLICATE_JACCARD_WITH_SHARED_FILE\n : DUPLICATE_JACCARD;\n if (similarity >= threshold && (!best || similarity > best.similarity)) {\n best = { record, similarity };\n }\n }\n return best;\n}\n\nfunction sanitizeAnchorFiles(rootDir: string, files: string[]): string[] {\n const resolvedRoot = path.resolve(rootDir);\n return files.filter((f) => {\n const resolved = path.resolve(resolvedRoot, f);\n return (\n resolved.startsWith(resolvedRoot) &&\n !f.startsWith(\"/\") &&\n !f.includes(\"..\")\n );\n });\n}\n\nexport interface UpsertDecisionInput {\n title: string;\n body: string;\n category: DecisionCategory;\n files?: string[];\n /** Existing id to update. Same id + unchanged content = re-verify (re-pin to HEAD). */\n id?: string;\n /** Id of a decision this one replaces; the old record is kept, marked superseded. */\n supersedes?: string;\n /** Save even when a near-duplicate was detected. */\n force?: boolean;\n}\n\nexport type UpsertDecisionResult =\n | {\n status: \"created\" | \"updated\" | \"reverified\" | \"superseded_and_created\";\n id: string;\n totalActive: number;\n warnings: string[];\n pruneCandidates?: string[];\n }\n | { status: \"duplicate_suspected\"; existing: DecisionRecord; hint: string }\n | { status: \"error\"; error: string };\n\nexport async function upsertDecision(\n rootDir: string,\n input: UpsertDecisionInput\n): Promise<UpsertDecisionResult> {\n const title = input.title.trim();\n const body = input.body.trim();\n if (title.length === 0 || body.length === 0) {\n return { status: \"error\", error: \"title and body must be non-empty\" };\n }\n if (title.length > TITLE_MAX_CHARS) {\n return {\n status: \"error\",\n error: `title exceeds ${TITLE_MAX_CHARS} chars — tighten it to a specific headline`,\n };\n }\n if (body.length > BODY_MAX_CHARS) {\n return {\n status: \"error\",\n error: `body exceeds ${BODY_MAX_CHARS} chars — record the decision, not the transcript`,\n };\n }\n\n const existing = await loadDecisions(rootDir);\n const byId = new Map(existing.map((r) => [r.id, r]));\n const now = new Date().toISOString();\n const head = await getCurrentGitHash(rootDir);\n const warnings: string[] = [];\n\n const files = sanitizeAnchorFiles(rootDir, input.files ?? []);\n if (input.files && files.length < input.files.length) {\n warnings.push(\"some anchor paths were outside the repo and were dropped\");\n }\n // Nonexistent anchors warn but save — a deprecation note may outlive its file.\n for (const f of files) {\n try {\n await fs.access(path.join(rootDir, f));\n } catch {\n warnings.push(`anchor file does not exist on disk: ${f}`);\n }\n }\n\n // Update / re-verify path\n if (input.id) {\n const record = byId.get(input.id);\n if (!record) {\n return { status: \"error\", error: `no decision with id \"${input.id}\"` };\n }\n const unchanged =\n record.title === title &&\n record.body === body &&\n record.category === input.category &&\n JSON.stringify(record.files) === JSON.stringify(files.length > 0 ? files : record.files);\n const updated: DecisionRecord = {\n ...record,\n title,\n body,\n category: input.category,\n files: input.files !== undefined ? files : record.files,\n updatedAt: now,\n refreshedHash: head,\n };\n await saveDecisionRecord(rootDir, updated);\n return {\n status: unchanged ? \"reverified\" : \"updated\",\n id: record.id,\n totalActive: existing.filter((r) => r.status === \"active\").length,\n warnings,\n };\n }\n\n // Create path — dedupe first\n if (!input.force) {\n const duplicate = findNearDuplicate({ title, body, files }, existing);\n if (duplicate) {\n return {\n status: \"duplicate_suspected\",\n existing: duplicate.record,\n hint: `A similar decision exists (\"${duplicate.record.title}\"). Call save_decision with id=\"${duplicate.record.id}\" to update/merge into it, or force:true if genuinely distinct.`,\n };\n }\n }\n\n // Supersede\n if (input.supersedes) {\n const old = byId.get(input.supersedes);\n if (!old) {\n return {\n status: \"error\",\n error: `no decision with id \"${input.supersedes}\" to supersede`,\n };\n }\n const id = decisionIdFor(title, body, new Set(byId.keys()));\n await saveDecisionRecord(rootDir, {\n ...old,\n status: \"superseded\",\n supersededBy: id,\n updatedAt: now,\n });\n const record: DecisionRecord = {\n version: 1,\n id,\n title,\n body,\n category: input.category,\n files,\n createdAt: now,\n updatedAt: now,\n refreshedHash: head,\n status: \"active\",\n };\n await saveDecisionRecord(rootDir, record);\n return {\n status: \"superseded_and_created\",\n id,\n totalActive: existing.filter((r) => r.status === \"active\").length,\n warnings,\n };\n }\n\n const id = decisionIdFor(title, body, new Set(byId.keys()));\n const record: DecisionRecord = {\n version: 1,\n id,\n title,\n body,\n category: input.category,\n files,\n createdAt: now,\n updatedAt: now,\n refreshedHash: head,\n status: \"active\",\n };\n await saveDecisionRecord(rootDir, record);\n\n const totalActive =\n existing.filter((r) => r.status === \"active\").length + 1;\n const result: UpsertDecisionResult = {\n status: \"created\",\n id,\n totalActive,\n warnings,\n };\n if (totalActive > MAX_ACTIVE_DECISIONS) {\n // Never auto-evict git-committed team knowledge — surface candidates\n // for a human cleanup PR instead.\n result.pruneCandidates = existing\n .filter((r) => r.status === \"superseded\")\n .map((r) => r.id)\n .slice(0, 10);\n warnings.push(\n `${totalActive} active decisions exceeds the soft cap of ${MAX_ACTIVE_DECISIONS} — consider a cleanup PR (superseded records first)`\n );\n }\n return result;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport fg from \"fast-glob\";\nimport { readFullFile } from \"../mcp/sampler.js\";\nimport { buildTestMap } from \"../test-map.js\";\n\nconst exec = promisify(execFile);\n\nexport interface FeatureEntry {\n description: string;\n files: string[];\n tests?: string[];\n /**\n * Commit this entry was last verified against. Entries updated by an\n * incremental save carry HEAD here; untouched entries keep the hash the\n * map had before the save, so drift stays visible per entry. Absent means\n * \"as of the snapshot's top-level gitHash\".\n */\n refreshedHash?: string;\n /**\n * Whether this is a user-facing capability or internal infrastructure\n * (DI wiring, config loading, logging, provider/transport plumbing).\n * Capabilities are published to product-facing docs (Confluence);\n * infrastructure stays in the AI concept map only. Defaults to \"capability\"\n * when absent (older snapshots) or unrecognized — see normalizeFeatureType.\n */\n type?: \"capability\" | \"infrastructure\";\n /**\n * When an assistant last confirmed this entry's files actually implement\n * the claimed feature (verify_snapshot flow). Absent on older snapshots\n * and never-verified entries. Drift checks freshness against git; this\n * checks the map was CORRECT in the first place.\n */\n verifiedAt?: string;\n /** Set when verification judged the entry wrong — re-map it. */\n verificationFailed?: boolean;\n verificationNote?: string;\n}\n\nexport type FeatureType = \"capability\" | \"infrastructure\";\n\n/**\n * Coerce an arbitrary type value to a known classification. Anything that\n * isn't explicitly \"infrastructure\" defaults to \"capability\" — so older\n * snapshots and unclassified entries are treated as user-facing (published),\n * never silently hidden.\n */\nexport function normalizeFeatureType(value: unknown): FeatureType {\n return value === \"infrastructure\" ? \"infrastructure\" : \"capability\";\n}\n\nexport interface FlowEntry {\n description: string;\n chain: string[];\n /** See FeatureEntry.refreshedHash. */\n refreshedHash?: string;\n /** See FeatureEntry.verifiedAt / verificationFailed. */\n verifiedAt?: string;\n verificationFailed?: boolean;\n verificationNote?: string;\n}\n\nexport interface Snapshot {\n version: 2;\n createdAt: string;\n updatedAt: string;\n gitHash: string;\n features: Record<string, FeatureEntry>;\n flows: Record<string, FlowEntry>;\n}\n\nfunction snapshotDir(rootDir: string): string {\n return path.join(rootDir, \".mason\");\n}\n\nfunction snapshotPath(rootDir: string): string {\n return path.join(snapshotDir(rootDir), \"snapshot.json\");\n}\n\nexport async function loadSnapshot(rootDir: string): Promise<Snapshot | null> {\n try {\n const raw = await fs.readFile(snapshotPath(rootDir), \"utf-8\");\n const parsed = JSON.parse(raw);\n // Skip v1 snapshots — they're the old per-file format\n if (parsed.version !== 2) return null;\n return parsed;\n } catch {\n return null;\n }\n}\n\nexport async function saveSnapshot(\n rootDir: string,\n snapshot: Snapshot\n): Promise<void> {\n await fs.mkdir(snapshotDir(rootDir), { recursive: true });\n await fs.writeFile(\n snapshotPath(rootDir),\n JSON.stringify(snapshot, null, 2),\n \"utf-8\"\n );\n}\n\nexport async function getCurrentGitHash(rootDir: string): Promise<string> {\n try {\n const { stdout } = await exec(\"git\", [\"rev-parse\", \"HEAD\"], {\n cwd: rootDir,\n });\n return stdout.trim();\n } catch {\n return \"unknown\";\n }\n}\n\nexport const SOURCE_GLOB =\n \"**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}\";\nexport const SOURCE_IGNORE = [\n \"**/node_modules/**\", \"**/dist/**\", \"**/build/**\", \"**/.gradle/**\",\n \"**/target/**\", \"**/.git/**\", \"**/vendor/**\", \"**/__pycache__/**\",\n \"**/venv/**\", \"**/.venv/**\", \"**/*.min.*\", \"**/*.map\",\n \"**/generated/**\", \"**/R.java\", \"**/BuildConfig.java\",\n];\n\nexport const DEFAULT_BATCH_SIZE = 50;\nconst SKELETON_CHARS = 500;\nconst DEEP_SAMPLE_CHARS = 1500;\nconst DEEP_SAMPLES_PER_BATCH = 3;\n\nexport interface SnapshotBatch {\n offset: number;\n batchSize: number;\n nextOffset: number | null;\n totalFiles: number;\n skeletons: Array<{ path: string; content: string }>;\n samples: Array<{ path: string; content: string }>;\n testPairs: Array<{ test: string; source: string; confidence: string }>;\n}\n\nexport async function listSourceFiles(resolvedRoot: string): Promise<string[]> {\n const all = await fg(SOURCE_GLOB, {\n cwd: resolvedRoot,\n ignore: SOURCE_IGNORE,\n });\n // Deterministic order so the same offset always returns the same batch.\n return [...all].sort();\n}\n\nexport async function prepareSnapshotBatch(\n rootDir: string,\n offset: number,\n batchSize: number = DEFAULT_BATCH_SIZE,\n scopeFiles?: string[]\n): Promise<SnapshotBatch> {\n const resolvedRoot = path.resolve(rootDir);\n let allFiles = await listSourceFiles(resolvedRoot);\n if (scopeFiles) {\n // Intersect with the real source list: keeps ignore rules and path safety,\n // and silently drops scope entries that no longer exist on disk. An empty\n // scope stays empty — it must not fall back to walking the whole project.\n const scopeSet = new Set(scopeFiles);\n allFiles = allFiles.filter((f) => scopeSet.has(f));\n }\n const totalFiles = allFiles.length;\n const safeOffset = Math.max(0, Math.min(offset, totalFiles));\n const batchPaths = allFiles.slice(safeOffset, safeOffset + batchSize);\n\n const skeletons: Array<{ path: string; content: string }> = [];\n for (const filePath of batchPaths) {\n const full = await readFullFile(resolvedRoot, filePath);\n if (full) {\n skeletons.push({\n path: full.path,\n content: full.content.slice(0, SKELETON_CHARS),\n });\n }\n }\n\n // Pick a few files from this batch to read deeply for grounding. Spread\n // evenly across the batch so the deep samples represent the batch's range.\n const samples: Array<{ path: string; content: string }> = [];\n if (skeletons.length > 0) {\n const step = Math.max(1, Math.floor(skeletons.length / DEEP_SAMPLES_PER_BATCH));\n for (let i = 0; i < skeletons.length && samples.length < DEEP_SAMPLES_PER_BATCH; i += step) {\n const full = await readFullFile(resolvedRoot, skeletons[i].path);\n if (full) {\n samples.push({\n path: full.path,\n content: full.content.slice(0, DEEP_SAMPLE_CHARS),\n });\n }\n }\n }\n\n // Only include test pairs that involve files in this batch — keeps the\n // appendix relevant and small.\n const batchPathSet = new Set(batchPaths);\n const allTestPairs = (await buildTestMap(resolvedRoot)).paired;\n const testPairs = allTestPairs.filter(\n (p) => batchPathSet.has(p.test) || batchPathSet.has(p.source)\n );\n\n const nextOffset =\n safeOffset + batchSize >= totalFiles ? null : safeOffset + batchSize;\n\n return {\n offset: safeOffset,\n batchSize,\n nextOffset,\n totalFiles,\n skeletons,\n samples,\n testPairs,\n };\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport fg from \"fast-glob\";\n\nconst exec = promisify(execFile);\n\nconst SOURCE_EXTENSIONS = [\n \"ts\", \"tsx\", \"js\", \"jsx\", \"mts\", \"mjs\",\n \"kt\", \"kts\", \"java\",\n \"py\",\n \"go\",\n \"rs\",\n \"swift\",\n \"rb\",\n \"cs\", \"cpp\", \"c\", \"h\",\n \"dart\",\n];\n\nconst CONFIG_FILES = [\n // Build & project config\n \"package.json\",\n \"tsconfig.json\",\n \"build.gradle.kts\",\n \"build.gradle\",\n \"settings.gradle.kts\",\n \"settings.gradle\",\n \"Cargo.toml\",\n \"go.mod\",\n \"pyproject.toml\",\n \"Gemfile\",\n \"*.csproj\",\n // Version catalogs & dependency locks\n \"gradle/libs.versions.toml\",\n // Code quality & formatting\n \".editorconfig\",\n \".eslintrc.*\",\n \"eslint.config.*\",\n \".prettierrc\",\n \"rustfmt.toml\",\n \".swiftlint.yml\",\n // CI/CD\n \".github/workflows/*.yml\",\n \".gitlab-ci.yml\",\n \"Jenkinsfile\",\n // Containerization\n \"Dockerfile\",\n \"docker-compose.yml\",\n \"docker-compose.yaml\",\n];\n\nconst ENTRY_POINT_PATTERNS = [\n \"src/main.*\",\n \"src/index.*\",\n \"src/app.*\",\n \"main.*\",\n \"index.*\",\n \"app.*\",\n \"App.*\",\n \"**/Main.kt\",\n \"**/Application.kt\",\n \"**/main.py\",\n \"**/main.go\",\n \"**/main.rs\",\n \"**/lib.rs\",\n \"**/Program.cs\",\n];\n\n// Filename patterns that reveal architectural patterns and conventions.\n// These are language-agnostic — the suffixes appear across ecosystems.\n// Ordered by architectural importance — most distinctive patterns first.\nconst ARCHITECTURAL_PATTERNS = [\n // State/data flow\n { glob: \"**/*ViewModel.*\", category: \"state\", reason: \"viewmodel (state management)\" },\n { glob: \"**/*Store.*\", category: \"state\", reason: \"store (state management)\" },\n { glob: \"**/*Reducer.*\", category: \"state\", reason: \"reducer (state management)\" },\n // Data layer — interface\n { glob: \"**/*Repository.*\", category: \"data-interface\", reason: \"repository interface (data layer contract)\" },\n { glob: \"**/*Dao.*\", category: \"data-interface\", reason: \"DAO (data access)\" },\n { glob: \"**/*DataSource.*\", category: \"data-interface\", reason: \"data source\" },\n // Data layer — implementation (where actual patterns live: mappers, retry, IO dispatchers)\n { glob: \"**/*RepositoryImpl.*\", category: \"data-impl\", reason: \"repository implementation (data layer patterns)\" },\n { glob: \"**/*ServiceImpl.*\", category: \"data-impl\", reason: \"service implementation\" },\n { glob: \"**/*Impl.*\", category: \"data-impl\", reason: \"implementation (concrete patterns)\" },\n // Data transformation\n { glob: \"**/*Mapper.*\", category: \"transform\", reason: \"mapper (data transformation)\" },\n { glob: \"**/*Converter.*\", category: \"transform\", reason: \"converter (data transformation)\" },\n { glob: \"**/*Adapter.*\", category: \"transform\", reason: \"adapter (interface adaptation)\" },\n // Dependency injection / wiring\n { glob: \"**/*Module.*\", category: \"di\", reason: \"module (DI/wiring)\" },\n { glob: \"**/*Provider.*\", category: \"di\", reason: \"provider (DI/wiring)\" },\n { glob: \"**/*Container.*\", category: \"di\", reason: \"container (DI/wiring)\" },\n { glob: \"**/*Factory.*\", category: \"di\", reason: \"factory (object creation)\" },\n // API / network\n { glob: \"**/*Service.*\", category: \"api\", reason: \"service (business/API layer)\" },\n { glob: \"**/*Client.*\", category: \"api\", reason: \"client (API/network layer)\" },\n { glob: \"**/*Api.*\", category: \"api\", reason: \"API interface definition\" },\n // Interface contracts / protocols\n { glob: \"**/*Interface.*\", category: \"contract\", reason: \"interface definition\" },\n { glob: \"**/*Protocol.*\", category: \"contract\", reason: \"protocol definition\" },\n { glob: \"**/*Trait.*\", category: \"contract\", reason: \"trait definition\" },\n // Routing / navigation\n { glob: \"**/*Router.*\", category: \"routing\", reason: \"router (navigation/routing)\" },\n { glob: \"**/*Route.*\", category: \"routing\", reason: \"route definition\" },\n { glob: \"**/*NavHost.*\", category: \"routing\", reason: \"navigation host\" },\n { glob: \"**/*Controller.*\", category: \"routing\", reason: \"controller (request handling)\" },\n { glob: \"**/*Handler.*\", category: \"routing\", reason: \"handler (request handling)\" },\n // Middleware / interceptors\n { glob: \"**/*Middleware.*\", category: \"middleware\", reason: \"middleware (request pipeline)\" },\n { glob: \"**/*Interceptor.*\", category: \"middleware\", reason: \"interceptor (cross-cutting)\" },\n { glob: \"**/*Plugin.*\", category: \"middleware\", reason: \"plugin (extensibility)\" },\n // Models / types\n { glob: \"**/*Model.*\", category: \"model\", reason: \"model (domain types)\" },\n { glob: \"**/*Entity.*\", category: \"model\", reason: \"entity (persistence types)\" },\n { glob: \"**/*Dto.*\", category: \"model\", reason: \"DTO (data transfer types)\" },\n { glob: \"**/*Schema.*\", category: \"model\", reason: \"schema (data validation)\" },\n // Use cases / commands\n { glob: \"**/*UseCase.*\", category: \"usecase\", reason: \"use case (business logic)\" },\n { glob: \"**/*Interactor.*\", category: \"usecase\", reason: \"interactor (business logic)\" },\n { glob: \"**/*Command.*\", category: \"usecase\", reason: \"command (CQRS pattern)\" },\n];\n\nconst IGNORE_PATTERNS = [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/.gradle/**\",\n \"**/target/**\",\n \"**/.git/**\",\n \"**/vendor/**\",\n \"**/__pycache__/**\",\n \"**/venv/**\",\n \"**/.venv/**\",\n \"**/*.min.*\",\n \"**/*.map\",\n \"**/package-lock.json\",\n \"**/yarn.lock\",\n \"**/pnpm-lock.yaml\",\n \"**/*.lock\",\n \"**/*.generated.*\",\n \"**/generated/**\",\n \"**/R.java\",\n \"**/BuildConfig.java\",\n];\n\nconst PREVIEW_LINES = 60;\n\nexport interface ProjectConfig {\n patterns?: string[];\n alwaysInclude?: string[];\n ignore?: string[];\n}\n\nexport interface SampledFile {\n path: string;\n preview: string;\n totalLines: number;\n sizeBytes: number;\n reason: string;\n}\n\nasync function loadProjectConfig(\n rootDir: string\n): Promise<ProjectConfig> {\n try {\n const raw = await fs.readFile(\n path.join(rootDir, \".mason\", \"config.json\"),\n \"utf-8\"\n );\n return JSON.parse(raw);\n } catch {\n return {};\n }\n}\n\nasync function getTrackedFiles(rootDir: string): Promise<Set<string> | null> {\n try {\n const { stdout } = await exec(\"git\", [\"ls-files\", \"--cached\", \"--others\", \"--exclude-standard\"], {\n cwd: rootDir,\n maxBuffer: 10_000_000,\n });\n return new Set(stdout.trim().split(\"\\n\").filter(Boolean));\n } catch {\n return null; // Not a git repo — skip filtering\n }\n}\n\nexport async function sampleFiles(\n rootDir: string,\n maxFiles: number = 25\n): Promise<SampledFile[]> {\n const selected = new Map<string, string>(); // path -> reason\n const projectConfig = await loadProjectConfig(rootDir);\n const ignorePatterns = [...IGNORE_PATTERNS, ...(projectConfig.ignore ?? [])];\n const trackedFiles = await getTrackedFiles(rootDir);\n\n // 0. Always-include files from project config (highest priority)\n for (const filePath of projectConfig.alwaysInclude ?? []) {\n if (selected.size >= maxFiles) break;\n // Validate path stays within project root\n const resolvedPath = path.resolve(rootDir, filePath);\n if (!resolvedPath.startsWith(path.resolve(rootDir))) continue;\n selected.set(filePath, \"always-include (project config)\");\n }\n\n // 1. Config files (cap at 5)\n let configCount = 0;\n for (const pattern of CONFIG_FILES) {\n if (configCount >= 5) break;\n const matches = await fg(pattern, {\n cwd: rootDir,\n ignore: ignorePatterns,\n deep: 3,\n });\n for (const match of matches) {\n if (configCount >= 5 || selected.size >= maxFiles) break;\n selected.set(match, \"config file\");\n configCount++;\n }\n }\n\n // 2. Module build/config files — build files from subdirectories reveal dependency graph\n const moduleBuildPatterns = [\n // Gradle\n \"**/build.gradle.kts\",\n \"**/build.gradle\",\n // Cargo workspace members\n \"**/Cargo.toml\",\n // Node workspaces\n \"**/package.json\",\n // Go sub-modules\n \"**/go.mod\",\n ];\n let moduleBuildCount = 0;\n for (const pattern of moduleBuildPatterns) {\n const matches = await fg(pattern, {\n cwd: rootDir,\n ignore: ignorePatterns,\n deep: 4,\n });\n // Skip root-level files (already captured as config)\n const subMatches = matches.filter((m) => m.includes(\"/\"));\n for (const match of subMatches) {\n if (moduleBuildCount >= 4 || selected.size >= maxFiles) break;\n if (!selected.has(match)) {\n selected.set(match, \"module build file (reveals dependency graph)\");\n moduleBuildCount++;\n }\n }\n if (moduleBuildCount >= 4) break;\n }\n\n // 3. Entry points (cap at 2)\n let entryCount = 0;\n for (const pattern of ENTRY_POINT_PATTERNS) {\n if (entryCount >= 2) break;\n const matches = await fg(pattern, {\n cwd: rootDir,\n ignore: ignorePatterns,\n deep: 5,\n });\n for (const match of matches) {\n if (entryCount >= 2 || selected.size >= maxFiles) break;\n if (!selected.has(match)) {\n selected.set(match, \"entry point\");\n entryCount++;\n }\n }\n }\n\n // 4. Hot files from git (up to 5)\n try {\n const { stdout } = await exec(\n \"git\",\n [\"log\", \"--since=3 months ago\", \"--format=\", \"--name-only\"],\n { cwd: rootDir, maxBuffer: 5_000_000 }\n );\n\n const fileCounts = new Map<string, number>();\n for (const line of stdout.split(\"\\n\")) {\n if (!line) continue;\n if (\n line.includes(\"node_modules\") ||\n line.includes(\"/build/\") ||\n line.includes(\".gradle\") ||\n line.includes(\"/generated/\")\n )\n continue;\n const ext = path.extname(line).slice(1);\n if (!SOURCE_EXTENSIONS.includes(ext)) continue;\n fileCounts.set(line, (fileCounts.get(line) ?? 0) + 1);\n }\n\n const hotFiles = [...fileCounts.entries()]\n .sort((a, b) => b[1] - a[1])\n .slice(0, 5);\n\n for (const [file, count] of hotFiles) {\n if (selected.size >= maxFiles) break;\n if (!selected.has(file)) {\n selected.set(file, `frequently changed (${count} commits in 3 months)`);\n }\n }\n } catch {\n // No git\n }\n\n // 5. Architectural pattern files — one per category (cap at 8)\n const seenCategories = new Set<string>();\n let patternCount = 0;\n for (const pattern of ARCHITECTURAL_PATTERNS) {\n if (patternCount >= 8 || selected.size >= maxFiles) break;\n if (seenCategories.has(pattern.category)) continue;\n\n const matches = await fg(pattern.glob, {\n cwd: rootDir,\n ignore: ignorePatterns,\n });\n\n if (matches.length > 0) {\n for (const match of matches) {\n if (!selected.has(match)) {\n selected.set(match, pattern.reason);\n seenCategories.add(pattern.category);\n patternCount++;\n break;\n }\n }\n }\n }\n\n // 5b. Custom patterns from project config\n for (const customGlob of projectConfig.patterns ?? []) {\n if (selected.size >= maxFiles) break;\n const matches = await fg(customGlob, {\n cwd: rootDir,\n ignore: ignorePatterns,\n });\n for (const match of matches) {\n if (selected.size >= maxFiles) break;\n if (!selected.has(match)) {\n selected.set(match, \"custom pattern (project config)\");\n break; // one per pattern\n }\n }\n }\n\n // 6. Test examples — diverse across file types (cap at 3)\n const testPatternGroups = [\n // JS/TS tests\n { patterns: [\"**/*.test.*\", \"**/*.spec.*\"], label: \"JS/TS test\" },\n // JVM tests\n { patterns: [\"**/*Test.kt\", \"**/*Test.java\"], label: \"JVM test\" },\n // Python tests\n { patterns: [\"**/test_*.py\", \"**/*_test.py\"], label: \"Python test\" },\n // Go tests\n { patterns: [\"**/*_test.go\"], label: \"Go test\" },\n // Swift tests\n { patterns: [\"**/*Tests.swift\", \"**/*Test.swift\"], label: \"Swift test\" },\n // Rust tests\n { patterns: [\"**/*_test.rs\"], label: \"Rust test\" },\n ];\n let testCount = 0;\n for (const group of testPatternGroups) {\n if (testCount >= 3 || selected.size >= maxFiles) break;\n const testFiles = await fg(group.patterns, {\n cwd: rootDir,\n ignore: ignorePatterns,\n });\n if (testFiles.length > 0) {\n for (const file of testFiles) {\n if (!selected.has(file)) {\n selected.set(file, `test example (${group.label})`);\n testCount++;\n break;\n }\n }\n }\n }\n\n // 7. Directory breadth — fill remaining slots with one file per top-level dir\n const sourceGlobs = SOURCE_EXTENSIONS.map((ext) => `**/*.${ext}`);\n const allSourceFiles = await fg(sourceGlobs, {\n cwd: rootDir,\n ignore: ignorePatterns,\n });\n\n const dirRepresentatives = new Map<string, string>();\n const boringFiles = /\\.(gradle|gradle\\.kts|json|toml|yaml|yml|xml|properties)$/;\n for (const file of allSourceFiles) {\n const topDir = file.split(\"/\")[0];\n if (!dirRepresentatives.has(topDir) && !boringFiles.test(file)) {\n dirRepresentatives.set(topDir, file);\n }\n }\n\n for (const [, file] of dirRepresentatives) {\n if (selected.size >= maxFiles) break;\n if (!selected.has(file)) {\n selected.set(file, \"directory representative\");\n }\n }\n\n // Read file previews\n const results: SampledFile[] = [];\n for (const [filePath, reason] of selected) {\n try {\n const fullPath = path.resolve(rootDir, filePath);\n if (!fullPath.startsWith(path.resolve(rootDir))) continue;\n if (isSensitiveFile(filePath)) continue;\n if (trackedFiles && !trackedFiles.has(filePath)) continue; // respect .gitignore\n const stat = await fs.stat(fullPath);\n if (stat.size > 100_000) continue;\n\n const content = await fs.readFile(fullPath, \"utf-8\");\n const lines = content.split(\"\\n\");\n const preview = lines.slice(0, PREVIEW_LINES).join(\"\\n\");\n\n results.push({\n path: filePath,\n preview,\n totalLines: lines.length,\n sizeBytes: stat.size,\n reason,\n });\n } catch {\n // Skip\n }\n }\n\n return results;\n}\n\nconst SENSITIVE_PATTERNS = [\n /^\\.env$/,\n /^\\.env\\./,\n /\\.pem$/,\n /\\.key$/,\n /\\.p12$/,\n /\\.pfx$/,\n /\\.jks$/,\n /id_rsa/,\n /id_ed25519/,\n /credentials\\./,\n /secret/i,\n /\\.keystore$/,\n /local\\.properties$/,\n];\n\nfunction isSensitiveFile(filePath: string): boolean {\n const basename = path.basename(filePath);\n return SENSITIVE_PATTERNS.some((p) => p.test(basename));\n}\n\nexport async function readFullFile(\n rootDir: string,\n filePath: string\n): Promise<{ path: string; content: string; totalLines: number } | null> {\n try {\n const fullPath = path.join(path.resolve(rootDir), filePath);\n if (!fullPath.startsWith(path.resolve(rootDir))) return null;\n if (isSensitiveFile(filePath)) return null;\n\n const content = await fs.readFile(fullPath, \"utf-8\");\n return {\n path: filePath,\n content,\n totalLines: content.split(\"\\n\").length,\n };\n } catch {\n return null;\n }\n}\n","import path from \"node:path\";\nimport fg from \"fast-glob\";\n\nconst IGNORE = [\n \"**/node_modules/**\",\n \"**/dist/**\",\n \"**/build/**\",\n \"**/.gradle/**\",\n \"**/target/**\",\n \"**/.git/**\",\n \"**/vendor/**\",\n \"**/__pycache__/**\",\n \"**/venv/**\",\n \"**/.venv/**\",\n \"**/*.min.*\",\n \"**/*.map\",\n];\n\nexport interface TestPair {\n test: string;\n source: string;\n confidence: string;\n}\n\nexport interface TestMapResult {\n totalTestFiles: number;\n paired: TestPair[];\n unmatched: string[];\n}\n\nexport async function buildTestMap(dir: string): Promise<TestMapResult> {\n const rootDir = path.resolve(dir);\n\n // Find all test files\n const testPatterns = [\n \"**/*.test.*\", \"**/*.spec.*\",\n \"**/*Test.kt\", \"**/*Test.java\", \"**/*Tests.kt\", \"**/*Tests.java\",\n \"**/test_*.py\", \"**/*_test.py\",\n \"**/*_test.go\",\n \"**/*Tests.swift\", \"**/*Test.swift\",\n \"**/*_test.rs\",\n ];\n const testFiles = await fg(testPatterns, { cwd: rootDir, ignore: IGNORE });\n\n // Find all source files\n const sourceFiles = await fg(\n \"**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,dart}\",\n { cwd: rootDir, ignore: IGNORE }\n );\n\n // Build source file index by base name (without extension)\n const sourceByBaseName = new Map<string, string[]>();\n for (const file of sourceFiles) {\n if (testFiles.includes(file)) continue; // Skip test files\n const baseName = path.basename(file).replace(/\\.[^.]+$/, \"\");\n const existing = sourceByBaseName.get(baseName) ?? [];\n existing.push(file);\n sourceByBaseName.set(baseName, existing);\n }\n\n // Match test files to source files by name\n const paired: TestPair[] = [];\n const unmatched: string[] = [];\n\n for (const testFile of testFiles) {\n const testBaseName = path.basename(testFile).replace(/\\.[^.]+$/, \"\");\n\n // Strip test suffixes/prefixes to get the source name\n const sourceName = testBaseName\n .replace(/Test$|Tests$|Spec$|\\.test$|\\.spec$/, \"\")\n .replace(/^test_|_test$/, \"\");\n\n if (!sourceName) {\n unmatched.push(testFile);\n continue;\n }\n\n const candidates = sourceByBaseName.get(sourceName);\n if (candidates && candidates.length > 0) {\n // If multiple candidates, prefer one in a similar directory path\n const testDir = path.dirname(testFile);\n const bestMatch = candidates.reduce((best, candidate) => {\n const candidateDir = path.dirname(candidate);\n const bestDir = path.dirname(best);\n const candidateOverlap = commonSegments(testDir, candidateDir);\n const bestOverlap = commonSegments(testDir, bestDir);\n return candidateOverlap > bestOverlap ? candidate : best;\n });\n\n paired.push({\n test: testFile,\n source: bestMatch,\n confidence: candidates.length === 1 ? \"exact\" : \"best-guess\",\n });\n } else {\n unmatched.push(testFile);\n }\n }\n\n return { totalTestFiles: testFiles.length, paired, unmatched };\n}\n\nfunction commonSegments(pathA: string, pathB: string): number {\n const segsA = pathA.split(\"/\");\n const segsB = pathB.split(\"/\");\n let count = 0;\n for (let i = 0; i < Math.min(segsA.length, segsB.length); i++) {\n if (segsA[i] === segsB[i]) count++;\n else break;\n }\n return count;\n}\n","import path from \"node:path\";\n\n// Question/filler words that carry no signal about which entry a task\n// touches. Domain words (\"auth\", \"drift\") are never in this list.\nconst STOPWORDS = new Set([\n \"the\", \"a\", \"an\", \"and\", \"or\", \"of\", \"to\", \"in\", \"on\", \"for\", \"with\",\n \"how\", \"does\", \"do\", \"is\", \"are\", \"was\", \"what\", \"where\", \"which\", \"why\",\n \"when\", \"who\", \"i\", \"we\", \"my\", \"our\", \"you\", \"your\", \"it\", \"its\", \"this\",\n \"that\", \"these\", \"those\", \"can\", \"could\", \"should\", \"would\", \"will\",\n \"want\", \"need\", \"please\", \"about\", \"into\", \"from\", \"when\", \"there\", \"any\",\n \"all\", \"some\", \"not\", \"but\", \"also\", \"just\", \"like\", \"get\", \"make\", \"use\",\n \"new\", \"work\", \"works\", \"working\", \"implement\", \"implemented\", \"change\",\n \"changed\", \"file\", \"files\", \"code\",\n]);\n\n/** Split camelCase/PascalCase/kebab/snake/path into lowercase word tokens. */\nexport function tokenize(text: string): string[] {\n return text\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter((t) => t.length > 2 && !STOPWORDS.has(t));\n}\n\n/** Crude singular/plural folding so \"flows\" matches \"flow\" etc. */\nexport function stem(token: string): string {\n return token.length > 3 && token.endsWith(\"s\") ? token.slice(0, -1) : token;\n}\n\nexport function tokenSet(text: string): Set<string> {\n return new Set(tokenize(text).map(stem));\n}\n\nexport interface Scorable {\n name: string;\n description: string;\n files: string[];\n}\n\n/**\n * Lexical relevance of one entry to the task. Name hits are the strongest\n * signal, then description, then file-path words. Each distinct task token\n * counts once at its best weight, so a token appearing everywhere doesn't\n * triple-count.\n */\nexport function scoreEntry(taskTokens: Set<string>, entry: Scorable): number {\n const nameTokens = tokenSet(entry.name);\n const descTokens = tokenSet(entry.description);\n const fileTokens = tokenSet(entry.files.map((f) => path.basename(f)).join(\" \"));\n\n let score = 0;\n for (const token of taskTokens) {\n if (nameTokens.has(token)) score += 3;\n else if (descTokens.has(token)) score += 1;\n else if (fileTokens.has(token)) score += 1;\n }\n return score;\n}\n\n/** Jaccard similarity of two token sets: |∩| / |∪|, 0 when both empty. */\nexport function jaccard(a: Set<string>, b: Set<string>): number {\n if (a.size === 0 && b.size === 0) return 0;\n let intersection = 0;\n for (const token of a) if (b.has(token)) intersection++;\n return intersection / (a.size + b.size - intersection);\n}\n","import path from \"node:path\";\nimport { getChangesWithStatus } from \"../drift/drift.js\";\nimport { getCurrentGitHash } from \"../snapshot/snapshot.js\";\nimport { loadDecisions } from \"./decisions.js\";\nimport type { DecisionRecord } from \"./decisions.js\";\n\n/**\n * Deliberately separate from DriftReport: mason-drift's exit codes and\n * --json shape are a CI contract, and `stale` there means MAP staleness.\n * Decision staleness is additive on top.\n */\nexport interface DecisionDriftReport {\n historyAvailable: boolean;\n totalDecisions: number;\n /** Decision id → anchor files changed since the record's refreshedHash. */\n staleDecisions: Record<string, string[]>;\n}\n\n/**\n * Flag active decisions whose anchor files changed since the record was\n * last verified. Anchorless decisions are pure prose and never go stale.\n * Deterministic — git only, no LLM.\n */\nexport async function computeDecisionDrift(\n rootDir: string,\n decisions?: DecisionRecord[]\n): Promise<DecisionDriftReport> {\n const resolvedRoot = path.resolve(rootDir);\n const records = decisions ?? (await loadDecisions(resolvedRoot));\n const report: DecisionDriftReport = {\n historyAvailable: true,\n totalDecisions: records.length,\n staleDecisions: {},\n };\n\n const head = await getCurrentGitHash(resolvedRoot);\n const changesByHash = new Map<string, Set<string> | null>();\n\n for (const record of records) {\n if (record.status !== \"active\" || record.files.length === 0) continue;\n if (record.refreshedHash === head) continue;\n\n let touched = changesByHash.get(record.refreshedHash);\n if (touched === undefined) {\n const changes = await getChangesWithStatus(\n resolvedRoot,\n record.refreshedHash\n );\n if (changes === null) {\n touched = null;\n } else {\n touched = new Set<string>();\n for (const change of changes) {\n touched.add(change.path);\n if (change.previousPath) touched.add(change.previousPath);\n }\n }\n changesByHash.set(record.refreshedHash, touched);\n }\n\n if (touched === null) {\n // Unreachable base commit — we know nothing per-file; surface that\n // rather than silently reporting the record fresh.\n report.historyAvailable = false;\n continue;\n }\n\n const hits = record.files.filter((f) => touched.has(f));\n if (hits.length > 0) {\n report.staleDecisions[record.id] = hits;\n }\n }\n\n return report;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport {\n loadSnapshot,\n getCurrentGitHash,\n listSourceFiles,\n} from \"../snapshot/snapshot.js\";\nimport type { Snapshot } from \"../snapshot/snapshot.js\";\n\nconst exec = promisify(execFile);\n\n// Incremental refresh stops paying off once a large share of the map is\n// touched — but small absolute counts are always cheap to refresh in place,\n// so both thresholds must be exceeded before recommending a full rebuild.\nconst FULL_REBUILD_FRACTION = 0.4;\nconst FULL_REBUILD_MIN_CHANGED_MAPPED_FILES = 10;\n\nexport type ChangeStatus = \"added\" | \"modified\" | \"deleted\" | \"renamed\";\n\nexport interface FileChange {\n status: ChangeStatus;\n /** Current path (the new path for renames). */\n path: string;\n /** Pre-rename path, only present for renames. */\n previousPath?: string;\n}\n\nexport type DriftRecommendation = \"up-to-date\" | \"incremental\" | \"full-rebuild\";\n\nexport interface DriftReport {\n stale: boolean;\n snapshotHash: string;\n headHash: string;\n /** Commits between the snapshot and HEAD; null when history is unavailable. */\n commitsBehind: number | null;\n /**\n * False when the snapshot commit is unreachable (shallow clone, rewritten\n * history) — staleFeatures/unmappedFiles/renames cannot be computed then.\n */\n historyAvailable: boolean;\n /** Current paths of every file changed since the snapshot. */\n changedFiles: string[];\n /** Stale feature name → the mapped files that changed under it. */\n staleFeatures: Record<string, string[]>;\n /** Stale flow name → the chain files that changed under it. */\n staleFlows: Record<string, string[]>;\n totalFeatures: number;\n totalFlows: number;\n /** New source files not referenced by any feature or flow. */\n unmappedFiles: string[];\n /** Files referenced by the map that no longer exist on disk. */\n ghostFiles: string[];\n renames: Array<{ from: string; to: string }>;\n recommendation: DriftRecommendation;\n}\n\nexport async function getChangesWithStatus(\n resolvedRoot: string,\n fromHash: string\n): Promise<FileChange[] | null> {\n if (!fromHash || fromHash === \"unknown\") return null;\n try {\n const { stdout } = await exec(\n \"git\",\n [\"diff\", \"--name-status\", \"-M\", fromHash, \"HEAD\"],\n { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }\n );\n\n const changes: FileChange[] = [];\n for (const line of stdout.split(\"\\n\")) {\n if (!line.trim()) continue;\n const parts = line.split(\"\\t\");\n // Mason's own metadata changes on every save — never count it as drift.\n if (parts.some((p) => p.startsWith(\".mason/\"))) continue;\n const code = parts[0];\n if (code.startsWith(\"R\") && parts.length >= 3) {\n changes.push({\n status: \"renamed\",\n path: parts[2],\n previousPath: parts[1],\n });\n } else if (code.startsWith(\"C\") && parts.length >= 3) {\n // A copy leaves the original in place — only the new path is a change.\n changes.push({ status: \"added\", path: parts[2] });\n } else if (code === \"A\" && parts.length >= 2) {\n changes.push({ status: \"added\", path: parts[1] });\n } else if (code === \"D\" && parts.length >= 2) {\n changes.push({ status: \"deleted\", path: parts[1] });\n } else if (parts.length >= 2) {\n // M, T (typechange), and anything unrecognized count as modified.\n changes.push({ status: \"modified\", path: parts[1] });\n }\n }\n return changes;\n } catch {\n return null;\n }\n}\n\nasync function countCommitsBehind(\n resolvedRoot: string,\n fromHash: string\n): Promise<number | null> {\n try {\n const { stdout } = await exec(\n \"git\",\n [\"rev-list\", \"--count\", `${fromHash}..HEAD`],\n { cwd: resolvedRoot }\n );\n const count = Number.parseInt(stdout.trim(), 10);\n return Number.isNaN(count) ? null : count;\n } catch {\n return null;\n }\n}\n\nfunction collectMappedFiles(snapshot: Snapshot): Set<string> {\n const mappedFiles = new Set<string>();\n for (const feature of Object.values(snapshot.features)) {\n for (const f of feature.files) mappedFiles.add(f);\n for (const t of feature.tests ?? []) mappedFiles.add(t);\n }\n for (const flow of Object.values(snapshot.flows)) {\n for (const f of flow.chain) mappedFiles.add(f);\n }\n return mappedFiles;\n}\n\nasync function findGhostFiles(\n resolvedRoot: string,\n mappedFiles: Set<string>\n): Promise<string[]> {\n const ghosts: string[] = [];\n for (const file of mappedFiles) {\n try {\n await fs.access(path.join(resolvedRoot, file));\n } catch {\n ghosts.push(file);\n }\n }\n return ghosts.sort();\n}\n\n/**\n * Compare the concept map against HEAD and report feature-level drift.\n * Fully deterministic — git + filesystem only, no LLM involved.\n * Returns null when no snapshot exists.\n */\nexport async function computeDrift(\n rootDir: string\n): Promise<DriftReport | null> {\n const resolvedRoot = path.resolve(rootDir);\n const snapshot = await loadSnapshot(resolvedRoot);\n if (!snapshot) return null;\n\n const headHash = await getCurrentGitHash(resolvedRoot);\n const totalFeatures = Object.keys(snapshot.features).length;\n const totalFlows = Object.keys(snapshot.flows).length;\n\n // Each entry is only verified as of its refreshedHash (falling back to the\n // top-level gitHash), so drift is evaluated per distinct hash — a partially\n // refreshed map can be fresh at the top level and still hold stale entries.\n const hashFor = (entry: { refreshedHash?: string }): string =>\n entry.refreshedHash ?? snapshot.gitHash;\n\n const distinctHashes = new Set<string>([snapshot.gitHash]);\n for (const feature of Object.values(snapshot.features)) {\n distinctHashes.add(hashFor(feature));\n }\n for (const flow of Object.values(snapshot.flows)) {\n distinctHashes.add(hashFor(flow));\n }\n distinctHashes.delete(\"unknown\");\n\n const staleHashes =\n headHash === \"unknown\"\n ? []\n : [...distinctHashes].filter((h) => h !== headHash);\n const stale = staleHashes.length > 0;\n\n const report: DriftReport = {\n stale,\n snapshotHash: snapshot.gitHash,\n headHash,\n commitsBehind: stale ? null : 0,\n historyAvailable: true,\n changedFiles: [],\n staleFeatures: {},\n staleFlows: {},\n totalFeatures,\n totalFlows,\n unmappedFiles: [],\n ghostFiles: [],\n renames: [],\n recommendation: \"up-to-date\",\n };\n\n if (!stale) return report;\n\n const mappedFiles = collectMappedFiles(snapshot);\n report.ghostFiles = await findGhostFiles(resolvedRoot, mappedFiles);\n\n const changesByHash = new Map<string, FileChange[]>();\n const touchedByHash = new Map<string, Set<string>>();\n for (const hash of staleHashes) {\n const changes = await getChangesWithStatus(resolvedRoot, hash);\n if (changes === null) {\n // One unreachable base commit is enough to make per-entry drift\n // uncomputable — we know the map is stale but not how.\n report.historyAvailable = false;\n report.recommendation = \"full-rebuild\";\n return report;\n }\n changesByHash.set(hash, changes);\n // Every path a change touches, old and new — an entry referencing either\n // side of a rename is stale.\n const touched = new Set<string>();\n for (const change of changes) {\n touched.add(change.path);\n if (change.previousPath) touched.add(change.previousPath);\n }\n touchedByHash.set(hash, touched);\n }\n\n // The oldest verification state in the map is the honest answer to \"how\n // far behind is this snapshot\".\n const commitCounts = await Promise.all(\n staleHashes.map((hash) => countCommitsBehind(resolvedRoot, hash))\n );\n const validCounts = commitCounts.filter((c): c is number => c !== null);\n report.commitsBehind =\n validCounts.length > 0 ? Math.max(...validCounts) : null;\n\n const emptySet = new Set<string>();\n const touchedFor = (entry: { refreshedHash?: string }): Set<string> =>\n touchedByHash.get(hashFor(entry)) ?? emptySet;\n\n for (const [name, feature] of Object.entries(snapshot.features)) {\n const touched = touchedFor(feature);\n const hits = [...feature.files, ...(feature.tests ?? [])].filter((f) =>\n touched.has(f)\n );\n if (hits.length > 0) report.staleFeatures[name] = [...new Set(hits)];\n }\n for (const [name, flow] of Object.entries(snapshot.flows)) {\n const touched = touchedFor(flow);\n const hits = flow.chain.filter((f) => touched.has(f));\n if (hits.length > 0) report.staleFlows[name] = [...new Set(hits)];\n }\n\n const allChanges = [...changesByHash.values()].flat();\n report.changedFiles = [...new Set(allChanges.map((c) => c.path))].sort();\n\n // New source files (added, or the new side of a rename) missing from the map.\n const sourceFileSet = new Set(await listSourceFiles(resolvedRoot));\n const newPaths = allChanges\n .filter((c) => c.status === \"added\" || c.status === \"renamed\")\n .map((c) => c.path);\n report.unmappedFiles = [...new Set(newPaths)]\n .filter((p) => sourceFileSet.has(p) && !mappedFiles.has(p))\n .sort();\n\n const renameKeys = new Set<string>();\n for (const change of allChanges) {\n if (change.status !== \"renamed\" || !change.previousPath) continue;\n const key = `${change.previousPath}\u0000${change.path}`;\n if (renameKeys.has(key)) continue;\n renameKeys.add(key);\n report.renames.push({ from: change.previousPath, to: change.path });\n }\n\n const changedMapped = new Set<string>([\n ...Object.values(report.staleFeatures).flat(),\n ...Object.values(report.staleFlows).flat(),\n ]);\n const changedFraction =\n mappedFiles.size > 0 ? changedMapped.size / mappedFiles.size : 0;\n report.recommendation =\n changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES &&\n changedFraction > FULL_REBUILD_FRACTION\n ? \"full-rebuild\"\n : \"incremental\";\n\n return report;\n}\n","import { runHook } from \"./hook.js\";\nimport type { HookEnv } from \"./hook.js\";\n\nexport const USAGE = `Usage: mason-hook [--print-config | --help]\n\nClaude Code PostToolUse hook: when the session reads or edits a file that a\nMason decision record anchors, the record is injected into the model's\ncontext. Deterministic lookup, no LLM call; silent when nothing matches.\n\nReads the hook JSON on stdin and prints the hook output JSON on stdout.\nRegister it via .claude/settings.json (committed to the repo, so the whole\nteam gets the same rail):\n\n mason-hook --print-config Print the settings.json hooks block\n\nRepeat injections are deduped per session; state lives in the OS temp dir.`;\n\nexport const SETTINGS_CONFIG = {\n hooks: {\n PostToolUse: [\n {\n matcher: \"Read|Edit|Write\",\n hooks: [\n {\n type: \"command\",\n command: \"npx -y -p mason-context mason-hook\",\n timeout: 10,\n },\n ],\n },\n ],\n },\n};\n\nexport interface HookCliIo {\n out: (line: string) => void;\n err: (line: string) => void;\n}\n\nexport async function runHookCli(\n argv: string[],\n stdinText: string,\n io: HookCliIo = {\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n },\n env: HookEnv = {}\n): Promise<number> {\n if (argv.includes(\"--help\") || argv.includes(\"-h\")) {\n io.out(USAGE);\n return 0;\n }\n if (argv.includes(\"--print-config\")) {\n io.out(JSON.stringify(SETTINGS_CONFIG, null, 2));\n return 0;\n }\n\n // A hook must never disrupt the session: any failure path is a silent\n // success with no output.\n try {\n const output = await runHook(stdinText, env);\n if (output !== null) io.out(output);\n } catch {\n // Silent by design.\n }\n return 0;\n}\n","import { runHookCli } from \"../src/hook/cli.js\";\n\nasync function readStdin(): Promise<string> {\n if (process.stdin.isTTY) return \"\";\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(Buffer.from(chunk));\n }\n return Buffer.concat(chunks).toString(\"utf-8\");\n}\n\nreadStdin()\n .then((stdinText) => runHookCli(process.argv.slice(2), stdinText))\n .then((code) => process.exit(code))\n .catch(() => process.exit(0));\n"],"mappings":"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","names":["fs","path","fs","path","fs","path","execFile","promisify","fg","path","fg","exec","promisify","execFile","exec","path","path","fs","path","fs","path","execFile","promisify","exec","promisify","execFile","exec","path","fs","path"]}
1
+ {"version":3,"sources":["../src/hook/hook.ts","../src/decisions/decisions.ts","../src/utils/storage.ts","../src/utils/paths.ts","../src/utils/files.ts","../src/snapshot/snapshot.ts","../src/test-map.ts","../src/context/lexical.ts","../src/decisions/provenance.ts","../src/decisions/drift.ts","../src/drift/drift.ts","../src/hook/cli.ts","../bin/mason-hook.ts"],"sourcesContent":["import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport os from \"node:os\";\nimport { loadDecisionStore } from \"../decisions/decisions.js\";\nimport { decisionProvenance } from \"../decisions/provenance.js\";\nimport { anchorMatches } from \"../utils/paths.js\";\nimport { createHash } from \"node:crypto\";\nimport type { Freshness } from \"../context/trust.js\";\nimport type { DecisionRecord } from \"../decisions/decisions.js\";\nimport { computeDecisionDrift } from \"../decisions/drift.js\";\n\n/** More than a few constraints per fire is noise, not context. */\nconst MAX_INJECTED_DECISIONS = 3;\n/** Walk-up bound when locating the Mason root from an edited file. */\nconst MAX_WALK_UP = 30;\n\nconst SUPPORTED_TOOLS = new Set([\"Read\", \"Edit\", \"Write\"]);\n\nexport interface HookStdin {\n session_id?: string;\n agent_id?: string;\n cwd?: string;\n hook_event_name?: string;\n tool_name?: string;\n tool_input?: { file_path?: string };\n}\n\nexport interface HookEnv {\n /** Override for tests; defaults to os.tmpdir(). */\n stateDir?: string;\n}\n\nasync function exists(p: string): Promise<boolean> {\n try {\n await fs.access(p);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Locate the nearest ancestor holding a .mason/decisions store. Stops at the\n * first .git boundary — a repo without Mason must stay silent, not borrow a\n * parent directory's decisions.\n */\nasync function findMasonRoot(startDir: string): Promise<string | null> {\n let dir = startDir;\n for (let i = 0; i < MAX_WALK_UP; i++) {\n if (await exists(path.join(dir, \".mason\", \"decisions\"))) return dir;\n if (await exists(path.join(dir, \".git\"))) return null;\n const parent = path.dirname(dir);\n if (parent === dir) return null;\n dir = parent;\n }\n return null;\n}\n\nfunction anchorsCover(record: DecisionRecord, relPath: string): boolean {\n return record.files.some(anchor => anchorMatches(anchor, relPath));\n}\n\nfunction exactAnchor(record: DecisionRecord, relPath: string): boolean {\n return record.files.some((a) => a.replace(/\\/+$/, \"\") === relPath);\n}\n\nfunction stateKey(input: HookStdin): string {\n const raw = `${input.session_id ?? \"nosession\"}${input.agent_id ? `-${input.agent_id}` : \"\"}`;\n return raw.replace(/[^A-Za-z0-9_-]/g, \"\").slice(0, 120) || \"nosession\";\n}\n\nasync function loadInjected(stateFile: string): Promise<Set<string>> {\n try {\n const parsed = JSON.parse(await fs.readFile(stateFile, \"utf-8\"));\n return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === \"string\") : []);\n } catch {\n return new Set();\n }\n}\n\nfunction formatContext(\n relPath: string,\n records: DecisionRecord[],\n freshness: Record<string, Freshness>\n): string {\n const lines: string[] = [];\n lines.push(\n `Mason: recorded knowledge anchored to ${relPath}. Accepted records are constraints subject to freshness; proposals are suggestions and legacy unreviewed records need confirmation. Retired or superseded records are no longer active. Do not modify decision records in .mason/decisions/.`\n );\n for (const record of records) {\n const provenance = decisionProvenance(record, freshness[record.id] ?? \"unknown\");\n const label = record.status === \"active\" ? provenance.approval : record.status;\n const stale = freshness[record.id] === \"current\" ? \"\" : freshness[record.id] === \"changed\"\n ? \" [recorded against changed files – verify against current code before relying on it]\"\n : \" [freshness unknown – verify against current code before relying on it]\";\n lines.push(\n `- [${label}] [${record.category}] ${record.title}: ${record.body} (anchors: ${record.files.join(\", \")}; owner: ${provenance.owner ?? \"unknown\"}; sources: ${provenance.sources.slice(0, 2).map(s => s.reference).join(\", \") || \"unrecorded\"})${stale}`\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * The push half of Mason's memory: when an agent touches a file that a\n * decision record anchors, the record is injected into the session via the\n * PostToolUse hook contract — deterministically, without relying on the\n * model deciding to ask. Returns the hook's stdout JSON, or null for\n * \"stay silent\" (no store, no match, already injected, malformed input —\n * a hook must never disrupt the session).\n */\nexport async function runHook(\n stdinText: string,\n env: HookEnv = {}\n): Promise<string | null> {\n let input: HookStdin;\n try {\n input = JSON.parse(stdinText);\n } catch {\n return null;\n }\n\n if (!input || typeof input !== \"object\") return null;\n if (input.tool_name && !SUPPORTED_TOOLS.has(input.tool_name)) return null;\n const filePath = input.tool_input?.file_path;\n if (!filePath || typeof filePath !== \"string\") return null;\n\n const absPath = path.isAbsolute(filePath)\n ? filePath\n : path.resolve(input.cwd ?? process.cwd(), filePath);\n const root = await findMasonRoot(path.dirname(absPath));\n if (!root) return null;\n const relPath = path.relative(root, absPath).split(path.sep).join(\"/\");\n if (relPath.startsWith(\"..\")) return null;\n\n const { records } = await loadDecisionStore(root);\n const matched = records.filter(r => anchorsCover(r, relPath));\n if (matched.length === 0) return null;\n\n const stateDir = env.stateDir ?? os.tmpdir();\n const stateFile = path.join(stateDir, `mason-hook-${createHash(\"sha256\").update(root).digest(\"hex\").slice(0, 12)}-${stateKey(input)}.json`);\n const injected = await loadInjected(stateFile);\n const recordKey = (record: DecisionRecord) => `${record.id}:${createHash(\"sha256\").update(JSON.stringify(record)).digest(\"hex\")}`;\n // Re-inject revised/accepted records and withdraw previously injected records\n // that were retired during this session. Untouched archived records stay dark.\n const fresh = matched.filter(r => !injected.has(recordKey(r)) &&\n (r.status === \"active\" || [...injected].some(key => key === r.id || key.startsWith(`${r.id}:`))));\n if (fresh.length === 0) return null;\n\n // Exact-file anchors outrank directory-prefix ones; newest knowledge wins ties.\n fresh.sort((a, b) => {\n const withdrawn = Number(b.status !== \"active\") - Number(a.status !== \"active\");\n if (withdrawn) return withdrawn;\n const exactDiff =\n Number(exactAnchor(b, relPath)) - Number(exactAnchor(a, relPath));\n if (exactDiff !== 0) return exactDiff;\n return b.updatedAt.localeCompare(a.updatedAt);\n });\n const selected = fresh.slice(0, MAX_INJECTED_DECISIONS);\n\n const drift = await computeDecisionDrift(root, selected);\n const freshness = drift.freshness ?? {};\n\n for (const record of selected) injected.add(recordKey(record));\n try {\n await fs.writeFile(stateFile, JSON.stringify([...injected]), \"utf-8\");\n } catch {\n // Dedupe state is a convenience; losing it must not block injection.\n }\n\n return JSON.stringify({\n hookSpecificOutput: {\n hookEventName: \"PostToolUse\",\n additionalContext: formatContext(relPath, selected, freshness),\n },\n });\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { createHash } from \"node:crypto\";\nimport { readStoreJson, writeStoreJson, storePath, type StoreDiagnostic } from \"../utils/storage.js\";\nimport { sanitizeRepoPaths } from \"../utils/paths.js\";\nimport { getCurrentGitHash } from \"../snapshot/snapshot.js\";\nimport { jaccard, tokenSet } from \"../context/lexical.js\";\nimport { attributionSchema, decisionSchema, decisionContent, decisionApproval, importLegacy, type DecisionSource, type DecisionRecord, type ReviewedDecisionRecord } from \"./provenance.js\";\nexport type { DecisionRecord } from \"./provenance.js\";\n\nexport type DecisionCategory =\n | \"decision\"\n | \"gotcha\"\n | \"deprecation\"\n | \"convention\";\nexport type DecisionStatus = \"active\" | \"superseded\" | \"retired\";\n\nexport const TITLE_MAX_CHARS = 80;\nexport const BODY_MAX_CHARS = 1500;\nexport const MAX_ACTIVE_DECISIONS = 150;\n\nconst DUPLICATE_JACCARD = 0.5;\nconst DUPLICATE_JACCARD_WITH_SHARED_FILE = 0.35;\n\nexport async function loadDecisionStore(rootDir: string): Promise<{ records: DecisionRecord[]; diagnostics: StoreDiagnostic[] }> {\n const records: DecisionRecord[] = [];\n const diagnostics: StoreDiagnostic[] = [];\n let entries: string[];\n try { entries = await fs.readdir(await storePath(rootDir, \".mason/decisions\")); }\n catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") diagnostics.push({ path: \".mason/decisions\", message: String(error) });\n return { records, diagnostics };\n }\n for (const entry of entries.sort()) {\n if (!entry.endsWith(\".json\")) continue;\n const relative = `.mason/decisions/${entry}`;\n try {\n const record = decisionSchema.parse(await readStoreJson(rootDir, relative));\n if (entry !== `${record.id}.json`) throw new Error(\"Record id does not match its filename\");\n records.push(record);\n } catch (error) { diagnostics.push({ path: relative, message: error instanceof Error ? error.message : String(error) }); }\n }\n return { records, diagnostics };\n}\n\nexport async function loadDecisions(rootDir: string): Promise<DecisionRecord[]> {\n return (await loadDecisionStore(rootDir)).records;\n}\n\nexport async function saveDecisionRecord(rootDir: string, record: DecisionRecord): Promise<void> {\n const validated = decisionSchema.parse(record);\n await writeStoreJson(rootDir, `.mason/decisions/${validated.id}.json`, validated);\n}\n\n/**\n * Deterministic, human-readable id: kebab slug of the title, ≤60 chars.\n * A slug collision with a DIFFERENT record appends a 6-hex content suffix.\n */\nexport function decisionIdFor(\n title: string,\n body: string,\n existingIds: Set<string>\n): string {\n const slug = title\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, \"-\")\n .replace(/^-+|-+$/g, \"\")\n .slice(0, 60)\n .replace(/-+$/, \"\");\n if (!existingIds.has(slug)) return slug || \"decision\";\n const suffix = createHash(\"sha1\")\n .update(title + body)\n .digest(\"hex\")\n .slice(0, 6);\n return `${slug}-${suffix}`;\n}\n\nexport function findNearDuplicate(\n candidate: { title: string; body: string; files: string[] },\n existing: DecisionRecord[]\n): { record: DecisionRecord; similarity: number } | null {\n const candidateTokens = tokenSet(`${candidate.title} ${candidate.body}`);\n const candidateFiles = new Set(candidate.files);\n let best: { record: DecisionRecord; similarity: number } | null = null;\n\n for (const record of existing) {\n if (record.status !== \"active\") continue;\n const similarity = jaccard(\n candidateTokens,\n tokenSet(`${record.title} ${record.body}`)\n );\n const sharesFile = record.files.some((f) => candidateFiles.has(f));\n const threshold = sharesFile\n ? DUPLICATE_JACCARD_WITH_SHARED_FILE\n : DUPLICATE_JACCARD;\n if (similarity >= threshold && (!best || similarity > best.similarity)) {\n best = { record, similarity };\n }\n }\n return best;\n}\n\nexport interface UpsertDecisionInput {\n title: string;\n body: string;\n category: DecisionCategory;\n files?: string[];\n owner?: string | null;\n sources?: DecisionSource[];\n /** Only supply a known identity; never infer authorship from Git configuration. */\n actor?: string;\n /** Existing id to revise. Unchanged content is a no-op; use review_decision to reaffirm. */\n id?: string;\n /** Id of a decision this one replaces; the old record is kept, marked superseded. */\n supersedes?: string;\n /** Save even when a near-duplicate was detected. */\n force?: boolean;\n}\n\nexport type UpsertDecisionResult =\n | {\n status: \"created\" | \"updated\" | \"unchanged\" | \"superseded_and_created\";\n id: string;\n totalActive: number;\n warnings: string[];\n approval?: \"unreviewed\" | \"proposed\" | \"accepted\";\n hint?: string;\n pruneCandidates?: string[];\n }\n | { status: \"duplicate_suspected\"; existing: DecisionRecord; hint: string }\n | { status: \"error\"; error: string };\n\n/** Serialize tool writes so prepared reviews cannot overwrite another decision edit. */\nexport async function withDecisionWrite<T>(root: string, operation: () => Promise<T>): Promise<T | { status: \"error\"; error: string }> {\n const lockPath = await storePath(root, \".mason/decisions/.write-lock\", true);\n let lock;\n try { lock = await fs.open(lockPath, \"wx\", 0o600); }\n catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"EEXIST\") return { status: \"error\", error: \"Decision store is locked by another write. Retry after it finishes; an abandoned .mason/decisions/.write-lock must be removed only after confirming no writer is running.\" };\n throw error;\n }\n try { return await operation(); }\n finally { await lock.close(); await fs.unlink(lockPath); }\n}\n\nexport async function upsertDecision(rootDir: string, input: UpsertDecisionInput): Promise<UpsertDecisionResult> {\n const title = input.title.trim(), body = input.body.trim();\n if (!title || !body) return { status: \"error\", error: \"title and body must be non-empty\" };\n if (title.length > TITLE_MAX_CHARS) return { status: \"error\", error: `title exceeds ${TITLE_MAX_CHARS} chars — tighten it to a specific headline` };\n if (body.length > BODY_MAX_CHARS) return { status: \"error\", error: `body exceeds ${BODY_MAX_CHARS} chars — record the decision, not the transcript` };\n const attribution = attributionSchema.safeParse(input);\n if (!attribution.success) return { status: \"error\", error: attribution.error.message };\n if (input.id && input.supersedes) return { status: \"error\", error: \"Use either id to revise or supersedes to replace a record, not both.\" };\n return withDecisionWrite(rootDir, async () => {\n const store = await loadDecisionStore(rootDir);\n if (store.diagnostics.length) return { status: \"error\", error: \"Repair malformed decision records before saving: \" + store.diagnostics.map(d => d.path).join(\", \") };\n const existing = store.records, byId = new Map(existing.map(r => [r.id, r]));\n const now = new Date().toISOString(), head = await getCurrentGitHash(rootDir);\n const warnings: string[] = [];\n const files = sanitizeRepoPaths(input.files ?? []);\n if (input.files && files.length < input.files.length) warnings.push(\"some anchor paths were outside the repo or duplicated and were dropped\");\n for (const file of files) {\n try { await fs.access(path.join(rootDir, file)); }\n catch { warnings.push(`anchor file does not exist on disk: ${file}`); }\n }\n const hint = \"Saved locally for review and commit. Proposals are not accepted constraints. Use review_decision to inspect evidence and record an authorized acceptance or reaffirmation.\";\n if (input.id) {\n const original = byId.get(input.id);\n if (!original) return { status: \"error\", error: `no decision with id \"${input.id}\"` };\n if (original.status !== \"active\") return { status: \"error\", error: \"Archived records cannot be revised; create a new proposal.\" };\n const record = importLegacy(original, now);\n const content = decisionContent({ ...record, title, body, category: input.category,\n files: input.files !== undefined ? files : record.files,\n owner: attribution.data.owner === undefined ? record.owner : attribution.data.owner ?? undefined,\n sources: attribution.data.sources ?? record.sources,\n });\n if (JSON.stringify(content) === JSON.stringify(decisionContent(record))) {\n return { status: \"unchanged\", id: record.id, totalActive: existing.filter(r => r.status === \"active\").length, approval: decisionApproval(original), warnings,\n hint: \"Unchanged content; no review or freshness stamp was written. Use review_decision for explicit re-verification.\" };\n }\n const revision = record.revision + 1;\n const updated: ReviewedDecisionRecord = { ...record, ...content, owner: content.owner, updatedAt: now, revision, approval: \"proposed\",\n history: [...record.history, { kind: \"revised\", at: now, actor: attribution.data.actor, revision, content, approval: \"proposed\", status: \"active\", refreshedHash: record.refreshedHash }],\n };\n await saveDecisionRecord(rootDir, updated);\n return { status: \"updated\", id: record.id, totalActive: existing.filter(r => r.status === \"active\").length, approval: \"proposed\", warnings, hint };\n }\n const old = input.supersedes ? byId.get(input.supersedes) : undefined;\n if (input.supersedes && !old) return { status: \"error\", error: `no decision with id \"${input.supersedes}\" to supersede` };\n if (old && (old.status !== \"active\" || decisionApproval(old) === \"accepted\")) {\n return { status: \"error\", error: \"A proposal cannot supersede an accepted or archived record. Create and review the replacement separately, then explicitly retire the old decision with review_decision.\" };\n }\n if (!input.force) {\n const duplicate = findNearDuplicate({ title, body, files }, existing);\n if (duplicate) return { status: \"duplicate_suspected\", existing: duplicate.record, hint: `A similar decision exists (\"${duplicate.record.title}\"). Call save_decision with id=\"${duplicate.record.id}\" to revise it, or force:true if distinct.` };\n }\n const id = decisionIdFor(title, body, new Set(byId.keys()));\n if (byId.has(id)) return { status: \"error\", error: `Decision id collision: ${id}. Choose a distinct title or revise the existing record.` };\n const content = decisionContent({ title, body, category: input.category, files, owner: attribution.data.owner ?? undefined, sources: attribution.data.sources ?? [] });\n const record: ReviewedDecisionRecord = { ...content, version: 2, id, createdAt: now, updatedAt: now, refreshedHash: head,\n status: \"active\", approval: \"proposed\", revision: 1,\n history: [{ kind: \"created\", at: now, actor: attribution.data.actor, revision: 1, content, approval: \"proposed\", status: \"active\", refreshedHash: head }],\n };\n // Write the replacement first: a failed second write leaves both records\n // available instead of removing the original before its replacement exists.\n await saveDecisionRecord(rootDir, record);\n if (old) {\n const imported = importLegacy(old, now);\n await saveDecisionRecord(rootDir, { ...imported, status: \"superseded\", supersededBy: id, updatedAt: now,\n history: [...imported.history, { kind: \"superseded\", at: now, actor: attribution.data.actor, note: `Replaced by proposal ${id}`,\n revision: imported.revision, content: decisionContent(imported), approval: imported.approval, status: \"superseded\", refreshedHash: imported.refreshedHash }],\n });\n }\n const totalActive = existing.filter(r => r.status === \"active\").length + (old ? 0 : 1);\n const result: UpsertDecisionResult = { status: old ? \"superseded_and_created\" : \"created\", id, totalActive, approval: \"proposed\", warnings, hint };\n if (totalActive > MAX_ACTIVE_DECISIONS) {\n result.pruneCandidates = existing.filter(r => r.status !== \"active\").map(r => r.id).slice(0, 10);\n warnings.push(`${totalActive} active decisions exceeds the soft cap of ${MAX_ACTIVE_DECISIONS} — consider a cleanup PR (archived records first)`);\n }\n return result;\n });\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { randomUUID } from \"node:crypto\";\nimport { normalizeRepoPath } from \"./paths.js\";\nimport { readBoundedFile } from \"./files.js\";\n\nexport interface StoreDiagnostic { path: string; message: string }\n\n/** Metadata paths may not contain symlinks, including their parent directories. */\nexport async function storePath(root: string, relative: string, createParents = false): Promise<string> {\n const normalized = normalizeRepoPath(relative);\n if (!normalized) throw new Error(`Invalid store path: ${relative}`);\n let current = await fs.realpath(root);\n const parts = normalized.split(\"/\");\n for (let i = 0; i < parts.length; i++) {\n current = path.join(current, parts[i]);\n let stat;\n try { stat = await fs.lstat(current); } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== \"ENOENT\") throw error;\n if (createParents && i < parts.length - 1) {\n try { await fs.mkdir(current); } catch (mkdirError) {\n if ((mkdirError as NodeJS.ErrnoException).code !== \"EEXIST\") throw mkdirError;\n }\n stat = await fs.lstat(current);\n }\n }\n if (stat?.isSymbolicLink()) throw new Error(`Symlink in store path: ${relative}`);\n }\n return current;\n}\n\nexport async function readStoreJson(root: string, relative: string): Promise<unknown | null> {\n try {\n const file = await storePath(root, relative);\n const raw = await readBoundedFile(file, 10 * 1024 * 1024);\n if (raw === null) throw new Error(\"file is not regular or exceeds 10 MiB\");\n const parsed: unknown = JSON.parse(raw);\n if (parsed === null) throw new Error(\"expected a JSON object, received null\");\n return parsed;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return null;\n throw new Error(`Invalid Mason store ${relative}: ${error instanceof Error ? error.message : String(error)}`);\n }\n}\n\nexport async function writeStoreJson(root: string, relative: string, value: unknown): Promise<void> {\n const payload = JSON.stringify(value, null, 2) + \"\\n\";\n if (Buffer.byteLength(payload) > 10 * 1024 * 1024) {\n throw new Error(`Mason store ${relative} exceeds 10 MiB`);\n }\n const file = await storePath(root, relative, true);\n const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${randomUUID()}.tmp`);\n try {\n const handle = await fs.open(temporary, \"wx\", 0o600);\n try { await handle.writeFile(payload, \"utf8\"); await handle.sync(); }\n finally { await handle.close(); }\n await fs.rename(temporary, file);\n } finally { await fs.rm(temporary, { force: true }); }\n}\n","import path from \"node:path\";\n\n/** One canonical representation for stored paths and decision anchors. */\nexport function normalizeRepoPath(value: string): string | null {\n const slash = value.replace(/\\\\/g, \"/\");\n if (!slash || slash.includes(\"\\0\") || path.posix.isAbsolute(slash) || /^[A-Za-z]:/.test(slash)) return null;\n if (slash.split(\"/\").includes(\"..\")) return null;\n const normalized = path.posix.normalize(slash).replace(/\\/$/, \"\");\n return normalized === \".\" ? null : normalized;\n}\n\nexport function sanitizeRepoPaths(files: string[]): string[] {\n return [...new Set(files.map(normalizeRepoPath).filter((p): p is string => p !== null))];\n}\n\nexport function isWithinRoot(root: string, candidate: string): boolean {\n const relative = path.relative(root, candidate);\n return relative !== \"..\" && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);\n}\n\nexport function anchorMatches(anchor: string, file: string): boolean {\n const a = normalizeRepoPath(anchor);\n const f = normalizeRepoPath(file);\n return a !== null && f !== null && (a === f || f.startsWith(`${a}/`));\n}\n\nexport function matchingPaths(anchors: string[], files: Iterable<string>): string[] {\n return [...new Set(files)].filter(file => anchors.some(anchor => anchorMatches(anchor, file)));\n}\n","import fs from \"node:fs/promises\";\nimport { constants } from \"node:fs\";\nimport path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport fg from \"fast-glob\";\nimport { isWithinRoot, normalizeRepoPath } from \"./paths.js\";\n\nconst exec = promisify(execFile);\nexport const SOURCE_EXTENSIONS = [\"ts\", \"tsx\", \"js\", \"jsx\", \"mts\", \"cts\", \"mjs\", \"cjs\", \"vue\", \"svelte\", \"kt\", \"kts\", \"java\", \"py\", \"go\", \"rs\", \"swift\", \"rb\", \"cs\", \"cpp\", \"c\", \"h\", \"hpp\", \"dart\", \"php\"];\nexport const SOURCE_GLOB = `**/*.{${SOURCE_EXTENSIONS.join(\",\")}}`;\nexport const SOURCE_IGNORE = [\n \"**/node_modules/**\", \"**/dist/**\", \"**/build/**\", \"**/.gradle/**\",\n \"**/target/**\", \"**/.git/**\", \"**/.mason/**\", \"**/vendor/**\", \"**/__pycache__/**\",\n \"**/venv/**\", \"**/.venv/**\", \"**/*.min.*\", \"**/*.map\", \"**/*.lock\",\n \"**/generated/**\", \"**/*.generated.*\", \"**/R.java\", \"**/BuildConfig.java\",\n \"**/package-lock.json\", \"**/yarn.lock\", \"**/pnpm-lock.yaml\",\n];\nexport const MAX_SOURCE_BYTES = 1024 * 1024;\nexport interface ProjectConfig { patterns?: string[]; alwaysInclude?: string[]; ignore?: string[] }\nexport interface SourceFile { path: string; content: string; totalLines: number }\n\nexport function isSensitiveFile(file: string): boolean {\n return file.split(/[\\\\/]/).some(part =>\n /^(?:\\.env(?:\\..*)?|id_rsa.*|id_ed25519.*)$|\\.(?:pem|key|p12|pfx|jks|keystore)$|credentials\\.|secret|^local\\.properties$/i.test(part)\n );\n}\n\n/** Bound reads even if a file grows after stat. Only read regular files. */\nexport async function readBoundedFile(file: string, maxBytes: number): Promise<string | null> {\n // Do not block on a FIFO or follow a symlink substituted after resolution.\n const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);\n try {\n const stat = await handle.stat();\n if (!stat.isFile() || stat.size > maxBytes) return null;\n const buffer = Buffer.alloc(Math.min(maxBytes + 1, stat.size + 1));\n let bytes = 0;\n while (bytes < buffer.length) {\n const result = await handle.read(buffer, bytes, buffer.length - bytes, null);\n if (result.bytesRead === 0) break;\n bytes += result.bytesRead;\n }\n return bytes === buffer.length ? null : buffer.subarray(0, bytes).toString(\"utf8\");\n } finally { await handle.close(); }\n}\n\nexport async function loadProjectConfig(root: string): Promise<ProjectConfig> {\n try {\n const canonicalRoot = await fs.realpath(root);\n const configPath = await fs.realpath(path.join(root, \".mason/config.json\"));\n if (!isWithinRoot(canonicalRoot, configPath)) throw new Error(\"Project configuration resolves outside the repository\");\n const raw = await readBoundedFile(configPath, 64 * 1024);\n if (raw === null) throw new Error(\"Project configuration is not a regular file or exceeds 64 KiB\");\n const value = JSON.parse(raw);\n if (!value || typeof value !== \"object\" || Array.isArray(value)) throw new Error(\"Expected a configuration object\");\n const config: ProjectConfig = {};\n for (const key of [\"patterns\", \"alwaysInclude\", \"ignore\"] as const) {\n if (value[key] === undefined) continue;\n if (!Array.isArray(value[key]) || !value[key].every((s: unknown) => typeof s === \"string\")) {\n throw new Error(`Configuration ${key} must be an array of strings`);\n }\n config[key] = value[key];\n }\n return config;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === \"ENOENT\") return {};\n throw new Error(`Cannot apply project file policy: ${error instanceof Error ? error.message : String(error)}`);\n }\n}\n\n/** Scoped to one operation, so a later tool call sees newly edited files/ignores. */\nexport async function createFileAccess(rootDir: string) {\n const root = path.resolve(rootDir);\n const canonicalRoot = await fs.realpath(root).catch(() => root);\n const config = await loadProjectConfig(root);\n const ignore = [...SOURCE_IGNORE, ...(config.ignore ?? [])];\n let gitFiles: Set<string> | null = null;\n try {\n const { stdout } = await exec(\"git\", [\"ls-files\", \"-z\", \"--cached\", \"--others\", \"--exclude-standard\"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });\n gitFiles = new Set(stdout.split(\"\\0\").filter(Boolean));\n } catch {\n // File-system projects are supported. Fail closed if this IS a Git repo.\n let inGit = false;\n try { await exec(\"git\", [\"rev-parse\", \"--git-dir\"], { cwd: root }); inGit = true; } catch { /* no Git */ }\n if (inGit) throw new Error(\"Cannot enumerate Git files safely\");\n }\n\n async function resolve(file: string): Promise<string | null> {\n const relative = normalizeRepoPath(file);\n if (!relative || isSensitiveFile(relative) || (gitFiles && !gitFiles.has(relative))) return null;\n const candidate = path.join(root, relative);\n try {\n const real = await fs.realpath(candidate);\n if (!isWithinRoot(canonicalRoot, real) || isSensitiveFile(path.relative(canonicalRoot, real))) return null;\n const stat = await fs.stat(real);\n if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES) return null;\n // A symlink must not bypass the target's ignore policy either.\n if (gitFiles && !gitFiles.has(path.relative(canonicalRoot, real).split(path.sep).join(\"/\"))) return null;\n return real;\n } catch { return null; }\n }\n\n async function list(patterns: string | string[] = SOURCE_GLOB, options: { deep?: number; dot?: boolean } = {}): Promise<string[]> {\n const found = await fg(patterns, { cwd: root, ignore, followSymbolicLinks: false, ...options });\n const safe = await Promise.all(found.map(async f => (await resolve(f)) ? f : null));\n return safe.filter((f): f is string => f !== null).sort();\n }\n\n async function read(file: string): Promise<SourceFile | null> {\n const relative = normalizeRepoPath(file);\n if (!relative) return null;\n const real = await resolve(relative);\n if (!real) return null;\n // Apply the same glob exclusions to explicit reads and symlink targets.\n for (const rel of new Set([relative, path.relative(canonicalRoot, real).split(path.sep).join(\"/\")])) {\n if (!(await fg(fg.escapePath(rel), { cwd: root, ignore, dot: true })).length) return null;\n }\n try {\n const content = await readBoundedFile(real, MAX_SOURCE_BYTES);\n return content === null ? null : { path: relative, content, totalLines: content.split(\"\\n\").length };\n } catch { return null; }\n }\n return { root, config, list, read };\n}\n","import path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport { createFileAccess } from \"../utils/files.js\";\nexport { SOURCE_GLOB, SOURCE_IGNORE } from \"../utils/files.js\";\nimport { readStoreJson, writeStoreJson, type StoreDiagnostic } from \"../utils/storage.js\";\nimport { z } from \"zod\";\nimport { normalizeRepoPath } from \"../utils/paths.js\";\nimport { buildTestMap } from \"../test-map.js\";\n\nconst exec = promisify(execFile);\n\nexport interface FeatureEntry {\n description: string;\n files: string[];\n tests?: string[];\n /**\n * Commit this entry was last verified against. Entries updated by an\n * incremental save carry HEAD here; untouched entries keep the hash the\n * map had before the save, so drift stays visible per entry. Absent means\n * \"as of the snapshot's top-level gitHash\".\n */\n refreshedHash?: string;\n /**\n * Whether this is a user-facing capability or internal infrastructure\n * (DI wiring, config loading, logging, provider/transport plumbing).\n * Capabilities are published to product-facing docs (Confluence);\n * infrastructure stays in the AI concept map only. Defaults to \"capability\"\n * when absent (older snapshots) — see normalizeFeatureType.\n */\n type?: \"capability\" | \"infrastructure\";\n /**\n * When an assistant last confirmed this entry's files actually implement\n * the claimed feature (verify_snapshot flow). Absent on older snapshots\n * and never-verified entries. Drift checks freshness against git; this\n * checks the map was CORRECT in the first place.\n */\n verifiedAt?: string;\n verifiedHash?: string;\n /** Set when verification judged the entry wrong — re-map it. */\n verificationFailed?: boolean;\n verificationNote?: string;\n}\n\nexport type FeatureType = \"capability\" | \"infrastructure\";\n\n/**\n * Coerce an arbitrary type value to a known classification. Anything that\n * isn't explicitly \"infrastructure\" defaults to \"capability\" — so older\n * snapshots and unclassified entries are treated as user-facing (published),\n * never silently hidden.\n */\nexport function normalizeFeatureType(value: unknown): FeatureType {\n return value === \"infrastructure\" ? \"infrastructure\" : \"capability\";\n}\n\nexport interface FlowEntry {\n description: string;\n chain: string[];\n /** See FeatureEntry.refreshedHash. */\n refreshedHash?: string;\n /** See FeatureEntry.verifiedAt / verificationFailed. */\n verifiedAt?: string;\n verifiedHash?: string;\n verificationFailed?: boolean;\n verificationNote?: string;\n}\n\nexport interface Snapshot {\n version: 2;\n createdAt: string;\n updatedAt: string;\n gitHash: string;\n features: Record<string, FeatureEntry>;\n flows: Record<string, FlowEntry>;\n}\n\nconst repoPath = z.string().refine(value => normalizeRepoPath(value) !== null, \"Expected a relative repository path\");\nconst verificationFields = {\n refreshedHash: z.string().optional(), verifiedAt: z.string().optional(),\n verifiedHash: z.string().optional(), verificationFailed: z.boolean().optional(),\n verificationNote: z.string().optional(),\n};\nexport const featureSchema = z.object({\n description: z.string(), files: z.array(repoPath), tests: z.array(repoPath).optional(),\n type: z.enum([\"capability\", \"infrastructure\"]).optional(), ...verificationFields,\n}).passthrough();\nexport const flowSchema = z.object({ description: z.string(), chain: z.array(repoPath), ...verificationFields }).passthrough();\nconst snapshotSchema = z.object({\n version: z.literal(2), createdAt: z.string(), updatedAt: z.string(), gitHash: z.string(),\n features: z.record(featureSchema), flows: z.record(flowSchema),\n}).passthrough();\n\nexport async function loadSnapshot(rootDir: string): Promise<Snapshot | null> {\n const parsed = await readStoreJson(rootDir, \".mason/snapshot.json\");\n if (parsed === null || (parsed as { version?: number }).version === 1) return null;\n const result = snapshotSchema.safeParse(parsed);\n if (!result.success) throw new Error(`Invalid Mason snapshot: ${result.error.message}`);\n return result.data;\n}\n\n/** Context and onboarding can use decisions even when the optional map is broken. */\nexport async function inspectSnapshot(rootDir: string): Promise<{\n status: \"available\" | \"missing\" | \"invalid\";\n snapshot: Snapshot | null;\n diagnostics: StoreDiagnostic[];\n}> {\n try {\n const raw = await readStoreJson(rootDir, \".mason/snapshot.json\");\n const snapshot = raw === null ? null : snapshotSchema.parse(raw);\n return { status: snapshot ? \"available\" : \"missing\", snapshot, diagnostics: [] };\n } catch (error) {\n return { status: \"invalid\", snapshot: null, diagnostics: [{\n path: \".mason/snapshot.json\", message: error instanceof Error ? error.message : String(error),\n }] };\n }\n}\n\nexport async function saveSnapshot(rootDir: string, snapshot: Snapshot): Promise<void> {\n await writeStoreJson(rootDir, \".mason/snapshot.json\", snapshotSchema.parse(snapshot));\n}\n\nexport async function getCurrentGitHash(rootDir: string): Promise<string> {\n try {\n const { stdout } = await exec(\"git\", [\"rev-parse\", \"HEAD\"], {\n cwd: rootDir,\n });\n return stdout.trim();\n } catch {\n return \"unknown\";\n }\n}\n\nexport const DEFAULT_BATCH_SIZE = 50;\nconst SKELETON_CHARS = 500;\nconst DEEP_SAMPLE_CHARS = 1500;\nconst DEEP_SAMPLES_PER_BATCH = 3;\n\nexport interface SnapshotBatch {\n offset: number;\n batchSize: number;\n nextOffset: number | null;\n totalFiles: number;\n skeletons: Array<{ path: string; content: string }>;\n samples: Array<{ path: string; content: string }>;\n testPairs: Array<{ test: string; source: string; confidence: string }>;\n}\n\nexport async function listSourceFiles(resolvedRoot: string): Promise<string[]> {\n return (await createFileAccess(resolvedRoot)).list();\n}\n\nexport async function prepareSnapshotBatch(\n rootDir: string,\n offset: number,\n batchSize: number = DEFAULT_BATCH_SIZE,\n scopeFiles?: string[]\n): Promise<SnapshotBatch> {\n const resolvedRoot = path.resolve(rootDir);\n const access = await createFileAccess(resolvedRoot);\n let allFiles = await access.list();\n if (scopeFiles) {\n // Intersect with the real source list: keeps ignore rules and path safety,\n // and silently drops scope entries that no longer exist on disk. An empty\n // scope stays empty — it must not fall back to walking the whole project.\n const scopeSet = new Set(scopeFiles);\n allFiles = allFiles.filter((f) => scopeSet.has(f));\n }\n const totalFiles = allFiles.length;\n const safeOffset = Math.max(0, Math.min(offset, totalFiles));\n const batchPaths = allFiles.slice(safeOffset, safeOffset + batchSize);\n\n const skeletons: Array<{ path: string; content: string }> = [];\n for (const filePath of batchPaths) {\n const full = await access.read(filePath);\n if (full) {\n skeletons.push({\n path: full.path,\n content: full.content.slice(0, SKELETON_CHARS),\n });\n }\n }\n\n // Pick a few files from this batch to read deeply for grounding. Spread\n // evenly across the batch so the deep samples represent the batch's range.\n const samples: Array<{ path: string; content: string }> = [];\n if (skeletons.length > 0) {\n const step = Math.max(1, Math.floor(skeletons.length / DEEP_SAMPLES_PER_BATCH));\n for (let i = 0; i < skeletons.length && samples.length < DEEP_SAMPLES_PER_BATCH; i += step) {\n const full = await access.read(skeletons[i].path);\n if (full) {\n samples.push({\n path: full.path,\n content: full.content.slice(0, DEEP_SAMPLE_CHARS),\n });\n }\n }\n }\n\n // Only include test pairs that involve files in this batch — keeps the\n // appendix relevant and small.\n const batchPathSet = new Set(batchPaths);\n const allTestPairs = (await buildTestMap(resolvedRoot)).paired;\n const testPairs = allTestPairs.filter(\n (p) => batchPathSet.has(p.test) || batchPathSet.has(p.source)\n );\n\n const nextOffset =\n safeOffset + batchSize >= totalFiles ? null : safeOffset + batchSize;\n\n return {\n offset: safeOffset,\n batchSize,\n nextOffset,\n totalFiles,\n skeletons,\n samples,\n testPairs,\n };\n}\n","import path from \"node:path\";\nimport { createFileAccess } from \"./utils/files.js\";\n\nexport interface TestPair {\n test: string;\n source: string;\n confidence: string;\n}\n\nexport interface TestMapResult {\n totalTestFiles: number;\n paired: TestPair[];\n unmatched: string[];\n}\n\nexport async function buildTestMap(dir: string): Promise<TestMapResult> {\n const rootDir = path.resolve(dir);\n const access = await createFileAccess(rootDir);\n\n // Find all test files\n const testPatterns = [\n \"**/*.test.*\", \"**/*.spec.*\",\n \"**/*Test.kt\", \"**/*Test.java\", \"**/*Tests.kt\", \"**/*Tests.java\",\n \"**/test_*.py\", \"**/*_test.py\",\n \"**/*_test.go\",\n \"**/*Tests.swift\", \"**/*Test.swift\",\n \"**/*_test.rs\",\n ];\n const testFiles = await access.list(testPatterns);\n\n // Find all source files\n const sourceFiles = await access.list();\n\n // Build source file index by base name (without extension)\n const sourceByBaseName = new Map<string, string[]>();\n for (const file of sourceFiles) {\n if (testFiles.includes(file)) continue; // Skip test files\n const baseName = path.basename(file).replace(/\\.[^.]+$/, \"\");\n const existing = sourceByBaseName.get(baseName) ?? [];\n existing.push(file);\n sourceByBaseName.set(baseName, existing);\n }\n\n // Match test files to source files by name\n const paired: TestPair[] = [];\n const unmatched: string[] = [];\n\n for (const testFile of testFiles) {\n const testBaseName = path.basename(testFile).replace(/\\.[^.]+$/, \"\");\n\n // Strip test suffixes/prefixes to get the source name\n const sourceName = testBaseName\n .replace(/Test$|Tests$|Spec$|\\.test$|\\.spec$/, \"\")\n .replace(/^test_|_test$/, \"\");\n\n if (!sourceName) {\n unmatched.push(testFile);\n continue;\n }\n\n const candidates = sourceByBaseName.get(sourceName);\n if (candidates && candidates.length > 0) {\n // If multiple candidates, prefer one in a similar directory path\n const testDir = path.dirname(testFile);\n const bestMatch = candidates.reduce((best, candidate) => {\n const candidateDir = path.dirname(candidate);\n const bestDir = path.dirname(best);\n const candidateOverlap = commonSegments(testDir, candidateDir);\n const bestOverlap = commonSegments(testDir, bestDir);\n return candidateOverlap > bestOverlap ? candidate : best;\n });\n\n paired.push({\n test: testFile,\n source: bestMatch,\n confidence: candidates.length === 1 ? \"exact\" : \"best-guess\",\n });\n } else {\n unmatched.push(testFile);\n }\n }\n\n return { totalTestFiles: testFiles.length, paired, unmatched };\n}\n\nfunction commonSegments(pathA: string, pathB: string): number {\n const segsA = pathA.split(\"/\");\n const segsB = pathB.split(\"/\");\n let count = 0;\n for (let i = 0; i < Math.min(segsA.length, segsB.length); i++) {\n if (segsA[i] === segsB[i]) count++;\n else break;\n }\n return count;\n}\n","import path from \"node:path\";\n\n// Question/filler words that carry no signal about which entry a task\n// touches. Domain words (\"auth\", \"drift\") are never in this list.\nconst STOPWORDS = new Set([\n \"the\", \"a\", \"an\", \"and\", \"or\", \"of\", \"to\", \"in\", \"on\", \"for\", \"with\",\n \"how\", \"does\", \"do\", \"is\", \"are\", \"was\", \"what\", \"where\", \"which\", \"why\",\n \"when\", \"who\", \"i\", \"we\", \"my\", \"our\", \"you\", \"your\", \"it\", \"its\", \"this\",\n \"that\", \"these\", \"those\", \"can\", \"could\", \"should\", \"would\", \"will\",\n \"want\", \"need\", \"please\", \"about\", \"into\", \"from\", \"when\", \"there\", \"any\",\n \"all\", \"some\", \"not\", \"but\", \"also\", \"just\", \"like\", \"get\", \"make\", \"use\",\n \"new\", \"work\", \"works\", \"working\", \"implement\", \"implemented\", \"change\",\n \"changed\", \"file\", \"files\", \"code\",\n]);\n\n/** Split camelCase/PascalCase/kebab/snake/path into lowercase word tokens. */\nexport function tokenize(text: string): string[] {\n return text\n .replace(/([a-z0-9])([A-Z])/g, \"$1 $2\")\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter((t) => t.length > 2 && !STOPWORDS.has(t));\n}\n\n/** Crude singular/plural folding so \"flows\" matches \"flow\" etc. */\nexport function stem(token: string): string {\n return token.length > 3 && token.endsWith(\"s\") ? token.slice(0, -1) : token;\n}\n\nexport function tokenSet(text: string): Set<string> {\n return new Set(tokenize(text).map(stem));\n}\n\nexport interface Scorable {\n name: string;\n description: string;\n files: string[];\n}\n\n/**\n * Lexical relevance of one entry to the task. Name hits are the strongest\n * signal, then description, then file-path words. Each distinct task token\n * counts once at its best weight, so a token appearing everywhere doesn't\n * triple-count.\n */\nexport function scoreEntry(taskTokens: Set<string>, entry: Scorable): number {\n const nameTokens = tokenSet(entry.name);\n const descTokens = tokenSet(entry.description);\n const fileTokens = tokenSet(entry.files.map((f) => path.basename(f)).join(\" \"));\n\n let score = 0;\n for (const token of taskTokens) {\n if (nameTokens.has(token)) score += 3;\n else if (descTokens.has(token)) score += 1;\n else if (fileTokens.has(token)) score += 1;\n }\n return score;\n}\n\n/** Jaccard similarity of two token sets: |∩| / |∪|, 0 when both empty. */\nexport function jaccard(a: Set<string>, b: Set<string>): number {\n if (a.size === 0 && b.size === 0) return 0;\n let intersection = 0;\n for (const token of a) if (b.has(token)) intersection++;\n return intersection / (a.size + b.size - intersection);\n}\n","import { z } from \"zod\";\nimport { normalizeRepoPath } from \"../utils/paths.js\";\nimport { assessTrust, type Freshness } from \"../context/trust.js\";\n\nconst text = (max: number) => z.string().trim().min(1).max(max);\nexport const decisionSourceSchema = z.object({\n kind: z.enum([\"pull_request\", \"issue\", \"incident\", \"discussion\", \"document\", \"other\"]),\n reference: text(1000),\n note: text(500).optional(),\n}).strict();\nexport type DecisionSource = z.infer<typeof decisionSourceSchema>;\nexport const attributionSchema = z.object({\n owner: text(200).nullable().optional(),\n sources: z.array(decisionSourceSchema).max(20).optional(),\n actor: text(200).optional(),\n});\nconst contentSchema = z.object({\n title: z.string().min(1), body: z.string().min(1),\n category: z.enum([\"decision\", \"gotcha\", \"deprecation\", \"convention\"]),\n files: z.array(z.string().refine(f => normalizeRepoPath(f) !== null)),\n owner: text(200).optional(), sources: z.array(decisionSourceSchema).max(20),\n});\nconst approvalSchema = z.enum([\"unreviewed\", \"proposed\", \"accepted\"]);\nconst statusSchema = z.enum([\"active\", \"superseded\", \"retired\"]);\nexport const reviewEvidenceSchema = z.object({\n baseHash: z.string(), headHash: z.string(), historyAvailable: z.boolean(),\n changedFiles: z.array(z.string()), localChanges: z.array(z.string()),\n});\nconst eventSchema = z.object({\n kind: z.enum([\"imported\", \"created\", \"revised\", \"accepted\", \"reaffirmed\", \"retired\", \"superseded\"]),\n at: z.string().datetime(), actor: text(200).optional(), note: text(1500).optional(),\n revision: z.number().int().positive(), content: contentSchema,\n approval: approvalSchema, status: statusSchema, refreshedHash: z.string(),\n evidence: reviewEvidenceSchema.optional(),\n});\nexport type DecisionEvent = z.infer<typeof eventSchema>;\nexport type DecisionApproval = z.infer<typeof approvalSchema>;\nexport type DecisionContent = z.infer<typeof contentSchema>;\n\nconst legacySchema = z.object({\n version: z.literal(1), id: z.string().regex(/^[a-zA-Z0-9_-]+$/),\n title: z.string().min(1), body: z.string().min(1),\n category: contentSchema.shape.category, files: contentSchema.shape.files,\n createdAt: z.string(), updatedAt: z.string(), refreshedHash: z.string(),\n status: z.enum([\"active\", \"superseded\"]), supersededBy: z.string().optional(),\n}).passthrough();\nconst currentSchema = legacySchema.extend({\n version: z.literal(2), status: statusSchema,\n approval: approvalSchema, revision: z.number().int().positive(),\n owner: text(200).optional(), sources: z.array(decisionSourceSchema).max(20),\n history: z.array(eventSchema).min(1),\n}).superRefine((record, ctx) => {\n const invalid = (message: string) => ctx.addIssue({ code: \"custom\", message });\n const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);\n let previous: DecisionEvent | undefined;\n for (const event of record.history) {\n if (!previous) {\n if (![\"created\", \"imported\"].includes(event.kind) || event.revision !== 1) invalid(\"History must begin with creation or legacy import at revision 1\");\n if (event.approval !== (event.kind === \"created\" ? \"proposed\" : \"unreviewed\")) invalid(\"Initial records cannot claim acceptance\");\n } else {\n if ([\"created\", \"imported\"].includes(event.kind)) invalid(\"History cannot restart\");\n if (previous.status !== \"active\") invalid(\"Archived decisions cannot be changed\");\n if (event.revision !== previous.revision + (event.kind === \"revised\" ? 1 : 0)) invalid(\"Invalid revision sequence\");\n if (event.kind !== \"revised\" && !same(event.content, previous.content)) invalid(\"A review cannot silently revise decision content\");\n if (event.kind === \"reaffirmed\" && previous.approval !== \"accepted\") invalid(\"Only accepted decisions can be reaffirmed\");\n if (event.kind === \"accepted\" && previous.approval === \"accepted\") invalid(\"Use reaffirmation for an accepted decision\");\n const approval = event.kind === \"revised\" ? \"proposed\" : [\"accepted\", \"reaffirmed\"].includes(event.kind) ? \"accepted\" : previous.approval;\n if (event.approval !== approval) invalid(\"Approval disagrees with review history\");\n if (![\"accepted\", \"reaffirmed\"].includes(event.kind) && event.refreshedHash !== previous.refreshedHash) invalid(\"Only a review can refresh the evidence baseline\");\n }\n if (event.kind !== \"imported\" && event.status !== (event.kind === \"retired\" ? \"retired\" : event.kind === \"superseded\" ? \"superseded\" : \"active\")) invalid(\"Lifecycle disagrees with history\");\n if ([\"accepted\", \"reaffirmed\", \"retired\"].includes(event.kind) && (!event.actor || !event.note || !event.evidence)) invalid(\"Reviews require a named reviewer, reason, and code evidence\");\n if ([\"accepted\", \"reaffirmed\"].includes(event.kind)) {\n if (!event.content.owner || !event.content.sources.length) invalid(\"Accepted decisions require an owner and source\");\n if (!event.evidence || !/^[a-f0-9]{40,64}$/.test(event.evidence.headHash) || event.refreshedHash !== event.evidence.headHash || event.evidence.localChanges.length) invalid(\"Acceptance requires a committed evidence baseline\");\n }\n previous = event;\n }\n if (!previous || !same(previous.content, decisionContent(record)) || previous.approval !== record.approval || previous.status !== record.status || previous.revision !== record.revision || previous.refreshedHash !== record.refreshedHash) invalid(\"Decision does not match the final history event\");\n});\n\nexport const decisionSchema = z.union([legacySchema, currentSchema]);\nexport type DecisionRecord = z.infer<typeof decisionSchema>;\nexport type ReviewedDecisionRecord = z.infer<typeof currentSchema>;\n\nexport function decisionContent(record: Pick<DecisionRecord, \"title\" | \"body\" | \"category\" | \"files\"> & { owner?: unknown; sources?: unknown }): DecisionContent {\n return { title: record.title, body: record.body, category: record.category, files: record.files,\n ...(typeof record.owner === \"string\" ? { owner: record.owner } : {}),\n sources: Array.isArray(record.sources) ? record.sources as DecisionSource[] : [],\n };\n}\n\n/** Reading legacy records never upgrades their approval or rewrites their files. */\nexport function decisionApproval(record: DecisionRecord): DecisionApproval {\n return record.version === 1 ? \"unreviewed\" : record.approval;\n}\n\nexport function importLegacy(record: DecisionRecord, now: string): ReviewedDecisionRecord {\n if (record.version === 2) return record;\n // Ignore unrecognized legacy fields: they are not evidence of authorship or approval.\n const content = decisionContent({ title: record.title, body: record.body, category: record.category, files: record.files });\n return { id: record.id, createdAt: record.createdAt, updatedAt: record.updatedAt, status: record.status, refreshedHash: record.refreshedHash, supersededBy: record.supersededBy, ...content, version: 2, approval: \"unreviewed\", revision: 1,\n history: [{ kind: \"imported\", at: now, revision: 1, content, approval: \"unreviewed\", status: record.status, refreshedHash: record.refreshedHash,\n note: \"Imported a legacy record. Prior authorship and review history are unknown.\" }],\n };\n}\n\nexport function decisionProvenance(record: DecisionRecord, freshness: Freshness = \"unknown\") {\n const approval = decisionApproval(record);\n const review = record.version === 2 ? [...record.history].reverse().find(e => [\"accepted\", \"reaffirmed\"].includes(e.kind) && e.revision === record.revision) : undefined;\n return {\n approval, revision: record.version === 2 ? record.revision : 0,\n owner: record.version === 2 ? record.owner ?? null : null,\n sources: record.version === 2 ? record.sources : [],\n guidance: record.status !== \"active\" ? \"historical\" : approval === \"accepted\" ? \"constraint\" : approval === \"proposed\" ? \"proposal\" : \"unreviewed\",\n reviewRequired: record.status === \"active\" && (approval !== \"accepted\" || freshness !== \"current\"),\n lastReview: review ? { reviewer: review.actor!, at: review.at, note: review.note!, gitHash: review.refreshedHash } : null,\n };\n}\n\nexport function decisionTrust(record: DecisionRecord, freshness: Freshness) {\n const review = decisionProvenance(record, freshness).lastReview;\n return assessTrust(review ? { verifiedAt: review.at, verifiedHash: review.gitHash } : {}, freshness);\n}\n\nexport const DECISION_GUIDANCE = \"Accepted decisions are recorded team constraints, subject to freshness checks. Proposals are suggestions; legacy unreviewed records need confirmation. Use review_decision to inspect provenance and record an authorized review; identities and sources are recorded assertions, not authenticated proof.\";\n","import path from \"node:path\";\nimport { getChangesWithStatus, getWorkingTree, touchedPaths } from \"../drift/drift.js\";\nimport { matchingPaths } from \"../utils/paths.js\";\nimport type { Freshness } from \"../context/trust.js\";\nimport type { StoreDiagnostic } from \"../utils/storage.js\";\nimport { getCurrentGitHash } from \"../snapshot/snapshot.js\";\nimport { loadDecisionStore } from \"./decisions.js\";\nimport type { DecisionRecord } from \"./decisions.js\";\n\n/**\n * Deliberately separate from DriftReport: mason-drift's exit codes and\n * --json shape are a CI contract, and `stale` there means MAP staleness.\n * Decision staleness is additive on top.\n */\nexport interface DecisionDriftReport {\n historyAvailable: boolean;\n freshness?: Record<string, Freshness>;\n diagnostics?: StoreDiagnostic[];\n totalDecisions: number;\n /** Decision id → anchor files changed since the record's refreshedHash. */\n staleDecisions: Record<string, string[]>;\n}\n\n/**\n * Flag active decisions whose anchor files changed since the record was\n * last verified. Anchorless decisions have unknown freshness and do not\n * contribute to committed drift.\n * Deterministic — git only, no LLM.\n */\nexport async function computeDecisionDrift(\n rootDir: string,\n decisions?: DecisionRecord[]\n): Promise<DecisionDriftReport> {\n const resolvedRoot = path.resolve(rootDir);\n const store = decisions ? { records: decisions, diagnostics: [] } : await loadDecisionStore(resolvedRoot);\n const report: DecisionDriftReport = { historyAvailable: true, totalDecisions: store.records.length, staleDecisions: {}, freshness: {}, diagnostics: store.diagnostics };\n const [head, workingTree] = await Promise.all([getCurrentGitHash(resolvedRoot), getWorkingTree(resolvedRoot)]);\n const changesByHash = new Map<string, string[] | null>();\n for (const record of store.records) {\n if (record.status !== \"active\") continue;\n if (record.files.length === 0) { report.freshness![record.id] = \"unknown\"; continue; }\n let touched = changesByHash.get(record.refreshedHash);\n if (touched === undefined) {\n const changes = record.refreshedHash === head && head !== \"unknown\" ? [] : await getChangesWithStatus(resolvedRoot, record.refreshedHash);\n touched = changes === null ? null : touchedPaths(changes);\n changesByHash.set(record.refreshedHash, touched);\n }\n if (touched === null) report.historyAvailable = false;\n const hits = touched ? matchingPaths(record.files, touched) : [];\n if (hits.length) report.staleDecisions[record.id] = hits;\n const localHits = matchingPaths(record.files, workingTree.changedFiles);\n report.freshness![record.id] = touched === null || !workingTree.available ? \"unknown\" : hits.length || localHits.length ? \"changed\" : \"current\";\n }\n return report;\n}\n","import fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\nimport {\n loadSnapshot,\n getCurrentGitHash,\n listSourceFiles,\n} from \"../snapshot/snapshot.js\";\nimport type { Snapshot } from \"../snapshot/snapshot.js\";\nimport type { Freshness } from \"../context/trust.js\";\nimport { matchingPaths } from \"../utils/paths.js\";\n\nconst exec = promisify(execFile);\n\n// Incremental refresh stops paying off once a large share of the map is\n// touched — but small absolute counts are always cheap to refresh in place,\n// so both thresholds must be exceeded before recommending a full rebuild.\nconst FULL_REBUILD_FRACTION = 0.4;\nconst FULL_REBUILD_MIN_CHANGED_MAPPED_FILES = 10;\n\nexport type ChangeStatus = \"added\" | \"modified\" | \"deleted\" | \"renamed\";\n\nexport interface FileChange {\n status: ChangeStatus;\n /** Current path (the new path for renames). */\n path: string;\n /** Pre-rename path, only present for renames. */\n previousPath?: string;\n}\n\nexport type DriftRecommendation = \"up-to-date\" | \"incremental\" | \"full-rebuild\";\n\nexport interface WorkingTreeReport {\n available: boolean;\n changedFiles: string[];\n untrackedFiles: string[];\n}\n\nexport interface DriftReport {\n /** Live entry state; committed drift alone continues to drive CLI exit codes. */\n featureFreshness?: Record<string, Freshness>;\n flowFreshness?: Record<string, Freshness>;\n workingTree?: WorkingTreeReport;\n verification?: { neverVerified: number; failed: string[] };\n stale: boolean;\n snapshotHash: string;\n headHash: string;\n /** Commits between the snapshot and HEAD; null when history is unavailable. */\n commitsBehind: number | null;\n /**\n * False when the snapshot commit is unreachable (shallow clone, rewritten\n * history) — staleFeatures/unmappedFiles/renames cannot be computed then.\n */\n historyAvailable: boolean;\n /** Current paths of every file changed since the snapshot. */\n changedFiles: string[];\n /** Stale feature name → the mapped files that changed under it. */\n staleFeatures: Record<string, string[]>;\n /** Stale flow name → the chain files that changed under it. */\n staleFlows: Record<string, string[]>;\n totalFeatures: number;\n totalFlows: number;\n /** New source files not referenced by any feature or flow. */\n unmappedFiles: string[];\n /** Files referenced by the map that no longer exist on disk. */\n ghostFiles: string[];\n renames: Array<{ from: string; to: string }>;\n recommendation: DriftRecommendation;\n}\n\nfunction parseChanges(output: string): FileChange[] {\n const fields = output.split(\"\\0\");\n const changes: FileChange[] = [];\n for (let i = 0; i < fields.length && fields[i];) {\n const code = fields[i++];\n const first = fields[i++];\n if (!first) break;\n const second = /^[RC]/.test(code) ? fields[i++] : undefined;\n const change: FileChange = second\n ? code.startsWith(\"R\") ? { status: \"renamed\", path: second, previousPath: first } : { status: \"added\", path: second }\n : { status: code === \"A\" ? \"added\" : code === \"D\" ? \"deleted\" : \"modified\", path: first };\n if (change.path.startsWith(\".mason/\") && (!change.previousPath || change.previousPath.startsWith(\".mason/\"))) continue;\n changes.push(change);\n }\n return changes;\n}\n\nexport function touchedPaths(changes: FileChange[]): string[] {\n return [...new Set(changes.flatMap(c => c.previousPath ? [c.previousPath, c.path] : [c.path]))].sort();\n}\n\nexport async function getChangesWithStatus(resolvedRoot: string, fromHash: string, toHash = \"HEAD\"): Promise<FileChange[] | null> {\n if (!fromHash || fromHash === \"unknown\" || fromHash.startsWith(\"-\") || !toHash || toHash === \"unknown\" || toHash.startsWith(\"-\")) return null;\n try {\n const { stdout } = await exec(\"git\", [\"diff\", \"--name-status\", \"-z\", \"-M\", fromHash, toHash, \"--\"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 });\n return parseChanges(stdout);\n } catch { return null; }\n}\n\nexport async function getWorkingTree(resolvedRoot: string): Promise<WorkingTreeReport> {\n try {\n const [diff, untracked] = await Promise.all([\n exec(\"git\", [\"diff\", \"--name-status\", \"-z\", \"-M\", \"HEAD\", \"--\"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),\n exec(\"git\", [\"ls-files\", \"-z\", \"--others\", \"--exclude-standard\"], { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }),\n ]);\n const untrackedFiles = untracked.stdout.split(\"\\0\").filter(f => f && !f.startsWith(\".mason/\"));\n return { available: true, changedFiles: [...new Set([...touchedPaths(parseChanges(diff.stdout)), ...untrackedFiles])].sort(), untrackedFiles };\n } catch { return { available: false, changedFiles: [], untrackedFiles: [] }; }\n}\n\nasync function countCommitsBehind(\n resolvedRoot: string,\n fromHash: string\n): Promise<number | null> {\n if (!fromHash || fromHash === \"unknown\" || fromHash.startsWith(\"-\")) return null;\n try {\n const { stdout } = await exec(\n \"git\",\n [\"rev-list\", \"--count\", `${fromHash}..HEAD`],\n { cwd: resolvedRoot }\n );\n const count = Number.parseInt(stdout.trim(), 10);\n return Number.isNaN(count) ? null : count;\n } catch {\n return null;\n }\n}\n\nfunction collectMappedFiles(snapshot: Snapshot): Set<string> {\n const mappedFiles = new Set<string>();\n for (const feature of Object.values(snapshot.features)) {\n for (const f of feature.files) mappedFiles.add(f);\n for (const t of feature.tests ?? []) mappedFiles.add(t);\n }\n for (const flow of Object.values(snapshot.flows)) {\n for (const f of flow.chain) mappedFiles.add(f);\n }\n return mappedFiles;\n}\n\nasync function findGhostFiles(\n resolvedRoot: string,\n mappedFiles: Set<string>\n): Promise<string[]> {\n const ghosts: string[] = [];\n for (const file of mappedFiles) {\n try {\n await fs.access(path.join(resolvedRoot, file));\n } catch {\n ghosts.push(file);\n }\n }\n return ghosts.sort();\n}\n\n/**\n * Compare the concept map against HEAD and report feature-level drift.\n * Fully deterministic — git + filesystem only, no LLM involved.\n * Returns null when no snapshot exists.\n */\nexport async function computeDrift(rootDir: string): Promise<DriftReport | null> {\n const root = path.resolve(rootDir);\n const snapshot = await loadSnapshot(root);\n if (!snapshot) return null;\n const [headHash, workingTree] = await Promise.all([getCurrentGitHash(root), getWorkingTree(root)]);\n const hashFor = (entry: { refreshedHash?: string }) => entry.refreshedHash ?? snapshot.gitHash;\n const entries = [...Object.values(snapshot.features), ...Object.values(snapshot.flows)];\n const hashes = new Set([snapshot.gitHash, ...entries.map(hashFor)]);\n const changesByHash = new Map<string, FileChange[] | null>();\n await Promise.all([...hashes].map(async hash => {\n changesByHash.set(hash, hash === headHash && headHash !== \"unknown\" ? [] : await getChangesWithStatus(root, hash));\n }));\n const historyAvailable = headHash !== \"unknown\" && [...changesByHash.values()].every(changes => changes !== null);\n const mappedFiles = collectMappedFiles(snapshot);\n const report: DriftReport = {\n stale: !historyAvailable,\n snapshotHash: snapshot.gitHash, headHash,\n commitsBehind: 0, historyAvailable,\n changedFiles: [], staleFeatures: {}, staleFlows: {},\n totalFeatures: Object.keys(snapshot.features).length,\n totalFlows: Object.keys(snapshot.flows).length,\n unmappedFiles: [], ghostFiles: await findGhostFiles(root, mappedFiles), renames: [],\n recommendation: historyAvailable ? \"up-to-date\" : \"full-rebuild\",\n featureFreshness: {}, flowFreshness: {}, workingTree,\n verification: {\n neverVerified: entries.filter(e => !e.verifiedAt).length,\n failed: [...Object.entries(snapshot.features), ...Object.entries(snapshot.flows)].filter(([, e]) => e.verificationFailed).map(([name]) => name),\n },\n };\n const counts = await Promise.all([...hashes].map(hash => hash === headHash ? 0 : countCommitsBehind(root, hash)));\n const knownCounts = counts.filter((n): n is number => n !== null);\n report.commitsBehind = knownCounts.length ? Math.max(...knownCounts) : null;\n\n const check = (name: string, files: string[], hash: string, staleEntries: Record<string, string[]>, freshness: Record<string, Freshness>) => {\n const changes = changesByHash.get(hash);\n const committedHits = changes ? matchingPaths(files, touchedPaths(changes)) : [];\n if (committedHits.length) staleEntries[name] = committedHits;\n const localHits = matchingPaths(files, workingTree.changedFiles);\n freshness[name] = files.length === 0 || changes === null || changes === undefined || !workingTree.available ? \"unknown\"\n : committedHits.length || localHits.length || files.some(f => report.ghostFiles.includes(f)) ? \"changed\" : \"current\";\n };\n for (const [name, feature] of Object.entries(snapshot.features)) {\n check(name, [...feature.files, ...(feature.tests ?? [])], hashFor(feature), report.staleFeatures, report.featureFreshness!);\n }\n for (const [name, flow] of Object.entries(snapshot.flows)) {\n check(name, flow.chain, hashFor(flow), report.staleFlows, report.flowFreshness!);\n }\n\n const allChanges = [...changesByHash.values()].flatMap(changes => changes ?? []);\n report.changedFiles = [...new Set(allChanges.map(c => c.path))].sort();\n // Complete coverage, including omissions from a map saved at HEAD. Untracked\n // files remain in workingTree and never change the committed-drift exit code.\n const sourceFiles = new Set(await listSourceFiles(root));\n let committedFiles: Set<string> = new Set();\n try {\n const { stdout } = await exec(\"git\", [\"ls-tree\", \"-r\", \"--name-only\", \"-z\", \"HEAD\"], { cwd: root, maxBuffer: 50 * 1024 * 1024 });\n committedFiles = new Set(stdout.split(\"\\0\").filter(Boolean));\n } catch { report.historyAvailable = false; report.stale = true; }\n report.unmappedFiles = [...sourceFiles].filter(f => committedFiles.has(f) && !mappedFiles.has(f)).sort();\n const renames = new Map<string, { from: string; to: string }>();\n for (const change of allChanges) {\n if (change.status === \"renamed\" && change.previousPath) renames.set(`${change.previousPath}\\0${change.path}`, { from: change.previousPath, to: change.path });\n }\n report.renames = [...renames.values()];\n const changedMapped = new Set([...Object.values(report.staleFeatures).flat(), ...Object.values(report.staleFlows).flat()]);\n // A locally deleted file is a live-edit warning, not committed map drift.\n const committedGhosts = report.ghostFiles.filter(f => !workingTree.changedFiles.includes(f));\n report.stale ||= changedMapped.size > 0 || report.unmappedFiles.length > 0 || committedGhosts.length > 0;\n if (!report.historyAvailable) report.recommendation = \"full-rebuild\";\n else if (!report.stale) report.recommendation = \"up-to-date\";\n else report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedMapped.size / Math.max(1, mappedFiles.size) > FULL_REBUILD_FRACTION ? \"full-rebuild\" : \"incremental\";\n return report;\n}\n","import { runHook } from \"./hook.js\";\nimport type { HookEnv } from \"./hook.js\";\n\nexport const USAGE = `Usage: mason-hook [--print-config | --help]\n\nClaude Code PostToolUse hook: when the session reads or edits a file that a\nMason decision record anchors, the record is injected into the model's\ncontext. Deterministic lookup, no LLM call; silent when nothing matches.\n\nReads the hook JSON on stdin and prints the hook output JSON on stdout.\nRegister it via .claude/settings.json (committed to the repo, so the whole\nteam gets the same rail):\n\n mason-hook --print-config Print the settings.json hooks block\n\nRepeat injections are deduped per session; state lives in the OS temp dir.`;\n\nexport const SETTINGS_CONFIG = {\n hooks: {\n PostToolUse: [\n {\n matcher: \"Read|Edit|Write\",\n hooks: [\n {\n type: \"command\",\n command: \"npx -y -p mason-context mason-hook\",\n timeout: 10,\n },\n ],\n },\n ],\n },\n};\n\nexport interface HookCliIo {\n out: (line: string) => void;\n err: (line: string) => void;\n}\n\nexport async function runHookCli(\n argv: string[],\n stdinText: string,\n io: HookCliIo = {\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n },\n env: HookEnv = {}\n): Promise<number> {\n if (argv.includes(\"--help\") || argv.includes(\"-h\")) {\n io.out(USAGE);\n return 0;\n }\n if (argv.includes(\"--print-config\")) {\n io.out(JSON.stringify(SETTINGS_CONFIG, null, 2));\n return 0;\n }\n\n // A hook must never disrupt the session: any failure path is a silent\n // success with no output.\n try {\n const output = await runHook(stdinText, env);\n if (output !== null) io.out(output);\n } catch {\n // Silent by design.\n }\n return 0;\n}\n","import { runHookCli } from \"../src/hook/cli.js\";\n\nasync function readStdin(): Promise<string> {\n if (process.stdin.isTTY) return \"\";\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(Buffer.from(chunk));\n }\n return Buffer.concat(chunks).toString(\"utf-8\");\n}\n\nconst argv = process.argv.slice(2);\nconst informational = argv.some(arg => [\"--help\", \"-h\", \"--print-config\"].includes(arg));\n\n(informational ? Promise.resolve(\"\") : readStdin())\n .then((stdinText) => runHookCli(argv, stdinText))\n .then((code) => process.exit(code))\n .catch(() => process.exit(0));\n"],"mappings":"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","names":["fs","path","fs","path","fs","path","path","fs","path","path","execFile","promisify","path","exec","promisify","execFile","exec","path","z","z","fs","createHash","path","fs","path","execFile","promisify","exec","promisify","execFile","exec","path","fs","path","createHash","argv"]}