mason-context 0.6.0 → 0.7.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.
- package/README.md +46 -1
- package/dist/mason-audit.js +1366 -0
- package/dist/mason-audit.js.map +1 -0
- package/dist/mason-drift.js.map +1 -1
- package/dist/mason-mcp.js +37 -22
- package/dist/mason-mcp.js.map +1 -1
- package/package.json +3 -2
package/dist/mason-drift.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/drift/cli.ts","../src/drift/drift.ts","../src/snapshot/snapshot.ts","../src/mcp/sampler.ts","../src/test-map.ts","../src/decisions/drift.ts","../src/decisions/decisions.ts","../src/context/lexical.ts","../bin/mason-drift.ts"],"sourcesContent":["import path from \"node:path\";\nimport { computeDrift } from \"./drift.js\";\nimport type { DriftReport } from \"./drift.js\";\nimport { computeDecisionDrift } from \"../decisions/drift.js\";\nimport type { DecisionDriftReport } from \"../decisions/drift.js\";\n\nexport const USAGE = `Usage: mason-drift [--dir <path>] [--json | --refresh-prompt]\n\nChecks the Mason concept map (.mason/snapshot.json) against git HEAD.\nDeterministic: no LLM call, no network — safe for CI.\n\nOptions:\n --dir <path> Project root to check (default: current directory)\n --json Print the full drift report as JSON\n --refresh-prompt When stale, print refresh instructions for ANY coding\n assistant with the Mason MCP server connected (Claude,\n Codex, Gemini, ...) — pipe it to your agent CLI to\n close the loop. Prints nothing extra when fresh.\n --help Show this help\n\nExit codes:\n 0 concept map is up to date\n 1 concept map is stale\n 2 error (no snapshot, not a git repository, bad arguments)`;\n\nexport interface DriftCliIo {\n out: (line: string) => void;\n err: (line: string) => void;\n}\n\ninterface ParsedArgs {\n dir: string;\n json: boolean;\n refreshPrompt: boolean;\n help: boolean;\n}\n\nfunction parseArgs(argv: string[]): ParsedArgs {\n const parsed: ParsedArgs = {\n dir: process.cwd(),\n json: false,\n refreshPrompt: false,\n help: false,\n };\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i];\n if (arg === \"--json\") {\n parsed.json = true;\n } else if (arg === \"--refresh-prompt\") {\n parsed.refreshPrompt = true;\n } else if (arg === \"--help\" || arg === \"-h\") {\n parsed.help = true;\n } else if (arg === \"--dir\") {\n const value = argv[++i];\n if (!value) throw new Error(\"--dir requires a path argument\");\n parsed.dir = value;\n } else if (!arg.startsWith(\"-\") && parsed.dir === process.cwd()) {\n parsed.dir = arg;\n } else {\n throw new Error(`Unknown argument: ${arg}`);\n }\n }\n return parsed;\n}\n\nexport function formatDriftSummary(report: DriftReport): string {\n if (!report.stale) {\n return `Concept map is up to date (HEAD ${report.headHash.slice(0, 7)}).`;\n }\n\n const lines: string[] = [];\n const behind =\n report.commitsBehind !== null\n ? `${report.commitsBehind} commit${report.commitsBehind === 1 ? \"\" : \"s\"} behind HEAD`\n : \"an unknown number of commits behind HEAD\";\n lines.push(`Concept map is STALE — ${behind}.`);\n\n if (!report.historyAvailable) {\n lines.push(\n \"The snapshot's base commit is unreachable (shallow clone or rewritten history); per-feature drift could not be computed.\"\n );\n }\n\n const staleFeatures = Object.keys(report.staleFeatures);\n const staleFlows = Object.keys(report.staleFlows);\n if (staleFeatures.length > 0) {\n lines.push(\n `Stale features (${staleFeatures.length}/${report.totalFeatures}): ${staleFeatures.join(\", \")}`\n );\n }\n if (staleFlows.length > 0) {\n lines.push(\n `Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(\", \")}`\n );\n }\n if (report.unmappedFiles.length > 0) {\n lines.push(\n `Unmapped new files (${report.unmappedFiles.length}): ${report.unmappedFiles.join(\", \")}`\n );\n }\n if (report.ghostFiles.length > 0) {\n lines.push(\n `Ghost files — mapped but deleted (${report.ghostFiles.length}): ${report.ghostFiles.join(\", \")}`\n );\n }\n lines.push(`Recommendation: ${report.recommendation}`);\n return lines.join(\"\\n\");\n}\n\n/**\n * Provider-neutral refresh instructions: any coding assistant with the\n * Mason MCP server connected can execute them — no Claude/Codex/Gemini\n * assumptions. This is the automation half of \"the map maintains itself\":\n * CI detects with this binary (free, deterministic), then pipes this\n * prompt to whatever headless agent the team runs.\n */\nexport function formatRefreshPrompt(\n report: DriftReport,\n decisionDrift: DecisionDriftReport\n): string {\n const lines: string[] = [];\n lines.push(\n \"The Mason concept map for this project is stale. Refresh it using the Mason MCP tools (server name: mason). Work autonomously; do not ask questions. Modify ONLY the concept map via Mason tools — do not edit source files.\"\n );\n lines.push(\"\");\n lines.push(\"DRIFT REPORT (deterministic, computed against git HEAD):\");\n lines.push(\n JSON.stringify(\n {\n commitsBehind: report.commitsBehind,\n recommendation: report.recommendation,\n staleFeatures: report.staleFeatures,\n staleFlows: report.staleFlows,\n changedFiles: report.changedFiles,\n unmappedFiles: report.unmappedFiles,\n ghostFiles: report.ghostFiles,\n renames: report.renames,\n },\n null,\n 2\n )\n );\n lines.push(\"\");\n\n const scopedFiles = [...report.changedFiles, ...report.unmappedFiles];\n if (!report.historyAvailable || report.recommendation === \"full-rebuild\") {\n lines.push(\n \"PROCEDURE (full rebuild): run the complete Map-Reduce build. Call generate_snapshot_batch repeatedly (follow nextOffset until null), calling save_partial_snapshot after each batch, then reduce_snapshot, then save_snapshot once with the unified map. Derive features ONLY from files shown in each batch prompt — never invent paths.\"\n );\n } else {\n lines.push(\n \"PROCEDURE (scoped refresh): call generate_snapshot_batch with the files list below — the SAME list on every call — following nextOffset until null, calling save_partial_snapshot after each batch. Then call reduce_snapshot (it merges into the existing map, preserving untouched entries) and save_snapshot once. Use save_snapshot's removeFeatures/removeFlows for features that no longer exist (see ghostFiles/renames). Derive features ONLY from files shown in each batch prompt — never invent paths.\"\n );\n lines.push(\"\");\n lines.push(`files: ${JSON.stringify(scopedFiles)}`);\n }\n\n const staleDecisionIds = Object.keys(decisionDrift.staleDecisions);\n if (staleDecisionIds.length > 0) {\n lines.push(\"\");\n lines.push(\n `NOTE: decisions [${staleDecisionIds.join(\", \")}] have anchor files that changed. Do NOT modify decision records in this automated run — they encode human knowledge. Mention them in your final summary so the team re-verifies them.`\n );\n }\n\n lines.push(\"\");\n lines.push(\n \"Finish by confirming the map was saved and summarizing which entries changed.\"\n );\n return lines.join(\"\\n\");\n}\n\nexport async function runDriftCli(\n argv: string[],\n io: DriftCliIo = {\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n }\n): Promise<number> {\n let args: ParsedArgs;\n try {\n args = parseArgs(argv);\n if (args.json && args.refreshPrompt) {\n throw new Error(\"--json and --refresh-prompt are mutually exclusive\");\n }\n } catch (error) {\n io.err(error instanceof Error ? error.message : String(error));\n io.err(USAGE);\n return 2;\n }\n\n if (args.help) {\n io.out(USAGE);\n return 0;\n }\n\n const rootDir = path.resolve(args.dir);\n const report = await computeDrift(rootDir);\n\n if (!report) {\n io.err(\n `No Mason snapshot found at ${path.join(rootDir, \".mason\", \"snapshot.json\")}. Build one via the Mason MCP server first.`\n );\n return 2;\n }\n\n if (report.headHash === \"unknown\") {\n io.err(\n `Could not determine git HEAD in ${rootDir} — not a git repository, or git is unavailable.`\n );\n return 2;\n }\n\n // Additive: decision staleness never changes exit codes — `stale` and the\n // 0/1/2 contract keep meaning MAP staleness for existing CI consumers.\n const decisionDrift = await computeDecisionDrift(rootDir);\n\n if (args.refreshPrompt) {\n io.out(\n report.stale\n ? formatRefreshPrompt(report, decisionDrift)\n : formatDriftSummary(report)\n );\n return report.stale ? 1 : 0;\n }\n\n if (args.json) {\n const output: DriftReport & { decisions?: DecisionDriftReport } = report;\n if (decisionDrift.totalDecisions > 0) {\n output.decisions = decisionDrift;\n }\n io.out(JSON.stringify(output, null, 2));\n } else {\n const lines = [formatDriftSummary(report)];\n const staleIds = Object.keys(decisionDrift.staleDecisions);\n if (staleIds.length > 0) {\n lines.push(\n `Decisions needing verification (${staleIds.length}/${decisionDrift.totalDecisions}): ${staleIds.join(\", \")}`\n );\n }\n io.out(lines.join(\"\\n\"));\n }\n return report.stale ? 1 : 0;\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 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\nconst SOURCE_GLOB =\n \"**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}\";\nconst 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\";\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 { 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 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 { runDriftCli } from \"../src/drift/cli.js\";\n\nrunDriftCli(process.argv.slice(2)).then(\n (code) => process.exit(code),\n (err) => {\n process.stderr.write(`mason-drift error: ${err}\\n`);\n process.exit(2);\n }\n);\n"],"mappings":";;;AAAA,OAAOA,WAAU;;;ACAjB,OAAOC,SAAQ;AACf,OAAOC,WAAU;AACjB,SAAS,YAAAC,iBAAgB;AACzB,SAAS,aAAAC,kBAAiB;;;ACH1B,OAAOC,SAAQ;AACf,OAAOC,WAAU;AACjB,SAAS,YAAAC,iBAAgB;AACzB,SAAS,aAAAC,kBAAiB;AAC1B,OAAOC,SAAQ;;;ACJf,OAAO,QAAQ;AACf,OAAO,UAAU;AACjB,SAAS,gBAAgB;AACzB,SAAS,iBAAiB;AAC1B,OAAO,QAAQ;AAEf,IAAM,OAAO,UAAU,QAAQ;;;ACN/B,OAAOC,WAAU;AACjB,OAAOC,SAAQ;;;AFOf,IAAMC,QAAOC,WAAUC,SAAQ;AAiE/B,SAAS,YAAY,SAAyB;AAC5C,SAAOC,MAAK,KAAK,SAAS,QAAQ;AACpC;AAEA,SAAS,aAAa,SAAyB;AAC7C,SAAOA,MAAK,KAAK,YAAY,OAAO,GAAG,eAAe;AACxD;AAEA,eAAsB,aAAa,SAA2C;AAC5E,MAAI;AACF,UAAM,MAAM,MAAMC,IAAG,SAAS,aAAa,OAAO,GAAG,OAAO;AAC5D,UAAM,SAAS,KAAK,MAAM,GAAG;AAE7B,QAAI,OAAO,YAAY,EAAG,QAAO;AACjC,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAcA,eAAsB,kBAAkB,SAAkC;AACxE,MAAI;AACF,UAAM,EAAE,OAAO,IAAI,MAAMC,MAAK,OAAO,CAAC,aAAa,MAAM,GAAG;AAAA,MAC1D,KAAK;AAAA,IACP,CAAC;AACD,WAAO,OAAO,KAAK;AAAA,EACrB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,IAAM,cACJ;AACF,IAAM,gBAAgB;AAAA,EACpB;AAAA,EAAsB;AAAA,EAAc;AAAA,EAAe;AAAA,EACnD;AAAA,EAAgB;AAAA,EAAc;AAAA,EAAgB;AAAA,EAC9C;AAAA,EAAc;AAAA,EAAe;AAAA,EAAc;AAAA,EAC3C;AAAA,EAAmB;AAAA,EAAa;AAClC;AAiBA,eAAsB,gBAAgB,cAAyC;AAC7E,QAAM,MAAM,MAAMC,IAAG,aAAa;AAAA,IAChC,KAAK;AAAA,IACL,QAAQ;AAAA,EACV,CAAC;AAED,SAAO,CAAC,GAAG,GAAG,EAAE,KAAK;AACvB;;;ADxIA,IAAMC,QAAOC,WAAUC,SAAQ;AAK/B,IAAM,wBAAwB;AAC9B,IAAM,wCAAwC;AAyC9C,eAAsB,qBACpB,cACA,UAC8B;AAC9B,MAAI,CAAC,YAAY,aAAa,UAAW,QAAO;AAChD,MAAI;AACF,UAAM,EAAE,OAAO,IAAI,MAAMF;AAAA,MACvB;AAAA,MACA,CAAC,QAAQ,iBAAiB,MAAM,UAAU,MAAM;AAAA,MAChD,EAAE,KAAK,cAAc,WAAW,KAAK,OAAO,KAAK;AAAA,IACnD;AAEA,UAAM,UAAwB,CAAC;AAC/B,eAAW,QAAQ,OAAO,MAAM,IAAI,GAAG;AACrC,UAAI,CAAC,KAAK,KAAK,EAAG;AAClB,YAAM,QAAQ,KAAK,MAAM,GAAI;AAE7B,UAAI,MAAM,KAAK,CAAC,MAAM,EAAE,WAAW,SAAS,CAAC,EAAG;AAChD,YAAM,OAAO,MAAM,CAAC;AACpB,UAAI,KAAK,WAAW,GAAG,KAAK,MAAM,UAAU,GAAG;AAC7C,gBAAQ,KAAK;AAAA,UACX,QAAQ;AAAA,UACR,MAAM,MAAM,CAAC;AAAA,UACb,cAAc,MAAM,CAAC;AAAA,QACvB,CAAC;AAAA,MACH,WAAW,KAAK,WAAW,GAAG,KAAK,MAAM,UAAU,GAAG;AAEpD,gBAAQ,KAAK,EAAE,QAAQ,SAAS,MAAM,MAAM,CAAC,EAAE,CAAC;AAAA,MAClD,WAAW,SAAS,OAAO,MAAM,UAAU,GAAG;AAC5C,gBAAQ,KAAK,EAAE,QAAQ,SAAS,MAAM,MAAM,CAAC,EAAE,CAAC;AAAA,MAClD,WAAW,SAAS,OAAO,MAAM,UAAU,GAAG;AAC5C,gBAAQ,KAAK,EAAE,QAAQ,WAAW,MAAM,MAAM,CAAC,EAAE,CAAC;AAAA,MACpD,WAAW,MAAM,UAAU,GAAG;AAE5B,gBAAQ,KAAK,EAAE,QAAQ,YAAY,MAAM,MAAM,CAAC,EAAE,CAAC;AAAA,MACrD;AAAA,IACF;AACA,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,mBACb,cACA,UACwB;AACxB,MAAI;AACF,UAAM,EAAE,OAAO,IAAI,MAAMA;AAAA,MACvB;AAAA,MACA,CAAC,YAAY,WAAW,GAAG,QAAQ,QAAQ;AAAA,MAC3C,EAAE,KAAK,aAAa;AAAA,IACtB;AACA,UAAM,QAAQ,OAAO,SAAS,OAAO,KAAK,GAAG,EAAE;AAC/C,WAAO,OAAO,MAAM,KAAK,IAAI,OAAO;AAAA,EACtC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,mBAAmB,UAAiC;AAC3D,QAAM,cAAc,oBAAI,IAAY;AACpC,aAAW,WAAW,OAAO,OAAO,SAAS,QAAQ,GAAG;AACtD,eAAW,KAAK,QAAQ,MAAO,aAAY,IAAI,CAAC;AAChD,eAAW,KAAK,QAAQ,SAAS,CAAC,EAAG,aAAY,IAAI,CAAC;AAAA,EACxD;AACA,aAAW,QAAQ,OAAO,OAAO,SAAS,KAAK,GAAG;AAChD,eAAW,KAAK,KAAK,MAAO,aAAY,IAAI,CAAC;AAAA,EAC/C;AACA,SAAO;AACT;AAEA,eAAe,eACb,cACA,aACmB;AACnB,QAAM,SAAmB,CAAC;AAC1B,aAAW,QAAQ,aAAa;AAC9B,QAAI;AACF,YAAMG,IAAG,OAAOC,MAAK,KAAK,cAAc,IAAI,CAAC;AAAA,IAC/C,QAAQ;AACN,aAAO,KAAK,IAAI;AAAA,IAClB;AAAA,EACF;AACA,SAAO,OAAO,KAAK;AACrB;AAOA,eAAsB,aACpB,SAC6B;AAC7B,QAAM,eAAeA,MAAK,QAAQ,OAAO;AACzC,QAAM,WAAW,MAAM,aAAa,YAAY;AAChD,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,WAAW,MAAM,kBAAkB,YAAY;AACrD,QAAM,gBAAgB,OAAO,KAAK,SAAS,QAAQ,EAAE;AACrD,QAAM,aAAa,OAAO,KAAK,SAAS,KAAK,EAAE;AAK/C,QAAM,UAAU,CAAC,UACf,MAAM,iBAAiB,SAAS;AAElC,QAAM,iBAAiB,oBAAI,IAAY,CAAC,SAAS,OAAO,CAAC;AACzD,aAAW,WAAW,OAAO,OAAO,SAAS,QAAQ,GAAG;AACtD,mBAAe,IAAI,QAAQ,OAAO,CAAC;AAAA,EACrC;AACA,aAAW,QAAQ,OAAO,OAAO,SAAS,KAAK,GAAG;AAChD,mBAAe,IAAI,QAAQ,IAAI,CAAC;AAAA,EAClC;AACA,iBAAe,OAAO,SAAS;AAE/B,QAAM,cACJ,aAAa,YACT,CAAC,IACD,CAAC,GAAG,cAAc,EAAE,OAAO,CAAC,MAAM,MAAM,QAAQ;AACtD,QAAM,QAAQ,YAAY,SAAS;AAEnC,QAAM,SAAsB;AAAA,IAC1B;AAAA,IACA,cAAc,SAAS;AAAA,IACvB;AAAA,IACA,eAAe,QAAQ,OAAO;AAAA,IAC9B,kBAAkB;AAAA,IAClB,cAAc,CAAC;AAAA,IACf,eAAe,CAAC;AAAA,IAChB,YAAY,CAAC;AAAA,IACb;AAAA,IACA;AAAA,IACA,eAAe,CAAC;AAAA,IAChB,YAAY,CAAC;AAAA,IACb,SAAS,CAAC;AAAA,IACV,gBAAgB;AAAA,EAClB;AAEA,MAAI,CAAC,MAAO,QAAO;AAEnB,QAAM,cAAc,mBAAmB,QAAQ;AAC/C,SAAO,aAAa,MAAM,eAAe,cAAc,WAAW;AAElE,QAAM,gBAAgB,oBAAI,IAA0B;AACpD,QAAM,gBAAgB,oBAAI,IAAyB;AACnD,aAAW,QAAQ,aAAa;AAC9B,UAAM,UAAU,MAAM,qBAAqB,cAAc,IAAI;AAC7D,QAAI,YAAY,MAAM;AAGpB,aAAO,mBAAmB;AAC1B,aAAO,iBAAiB;AACxB,aAAO;AAAA,IACT;AACA,kBAAc,IAAI,MAAM,OAAO;AAG/B,UAAM,UAAU,oBAAI,IAAY;AAChC,eAAW,UAAU,SAAS;AAC5B,cAAQ,IAAI,OAAO,IAAI;AACvB,UAAI,OAAO,aAAc,SAAQ,IAAI,OAAO,YAAY;AAAA,IAC1D;AACA,kBAAc,IAAI,MAAM,OAAO;AAAA,EACjC;AAIA,QAAM,eAAe,MAAM,QAAQ;AAAA,IACjC,YAAY,IAAI,CAAC,SAAS,mBAAmB,cAAc,IAAI,CAAC;AAAA,EAClE;AACA,QAAM,cAAc,aAAa,OAAO,CAAC,MAAmB,MAAM,IAAI;AACtE,SAAO,gBACL,YAAY,SAAS,IAAI,KAAK,IAAI,GAAG,WAAW,IAAI;AAEtD,QAAM,WAAW,oBAAI,IAAY;AACjC,QAAM,aAAa,CAAC,UAClB,cAAc,IAAI,QAAQ,KAAK,CAAC,KAAK;AAEvC,aAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,SAAS,QAAQ,GAAG;AAC/D,UAAM,UAAU,WAAW,OAAO;AAClC,UAAM,OAAO,CAAC,GAAG,QAAQ,OAAO,GAAI,QAAQ,SAAS,CAAC,CAAE,EAAE;AAAA,MAAO,CAAC,MAChE,QAAQ,IAAI,CAAC;AAAA,IACf;AACA,QAAI,KAAK,SAAS,EAAG,QAAO,cAAc,IAAI,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC;AAAA,EACrE;AACA,aAAW,CAAC,MAAM,IAAI,KAAK,OAAO,QAAQ,SAAS,KAAK,GAAG;AACzD,UAAM,UAAU,WAAW,IAAI;AAC/B,UAAM,OAAO,KAAK,MAAM,OAAO,CAAC,MAAM,QAAQ,IAAI,CAAC,CAAC;AACpD,QAAI,KAAK,SAAS,EAAG,QAAO,WAAW,IAAI,IAAI,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC;AAAA,EAClE;AAEA,QAAM,aAAa,CAAC,GAAG,cAAc,OAAO,CAAC,EAAE,KAAK;AACpD,SAAO,eAAe,CAAC,GAAG,IAAI,IAAI,WAAW,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,EAAE,KAAK;AAGvE,QAAM,gBAAgB,IAAI,IAAI,MAAM,gBAAgB,YAAY,CAAC;AACjE,QAAM,WAAW,WACd,OAAO,CAAC,MAAM,EAAE,WAAW,WAAW,EAAE,WAAW,SAAS,EAC5D,IAAI,CAAC,MAAM,EAAE,IAAI;AACpB,SAAO,gBAAgB,CAAC,GAAG,IAAI,IAAI,QAAQ,CAAC,EACzC,OAAO,CAAC,MAAM,cAAc,IAAI,CAAC,KAAK,CAAC,YAAY,IAAI,CAAC,CAAC,EACzD,KAAK;AAER,QAAM,aAAa,oBAAI,IAAY;AACnC,aAAW,UAAU,YAAY;AAC/B,QAAI,OAAO,WAAW,aAAa,CAAC,OAAO,aAAc;AACzD,UAAM,MAAM,GAAG,OAAO,YAAY,KAAI,OAAO,IAAI;AACjD,QAAI,WAAW,IAAI,GAAG,EAAG;AACzB,eAAW,IAAI,GAAG;AAClB,WAAO,QAAQ,KAAK,EAAE,MAAM,OAAO,cAAc,IAAI,OAAO,KAAK,CAAC;AAAA,EACpE;AAEA,QAAM,gBAAgB,oBAAI,IAAY;AAAA,IACpC,GAAG,OAAO,OAAO,OAAO,aAAa,EAAE,KAAK;AAAA,IAC5C,GAAG,OAAO,OAAO,OAAO,UAAU,EAAE,KAAK;AAAA,EAC3C,CAAC;AACD,QAAM,kBACJ,YAAY,OAAO,IAAI,cAAc,OAAO,YAAY,OAAO;AACjE,SAAO,iBACL,cAAc,QAAQ,yCACtB,kBAAkB,wBACd,iBACA;AAEN,SAAO;AACT;;;AI9RA,OAAOC,WAAU;;;ACAjB,OAAOC,SAAQ;AACf,OAAOC,WAAU;AACjB,SAAS,kBAAkB;;;ACF3B,OAAOC,WAAU;;;AD4CjB,SAAS,aAAa,SAAyB;AAC7C,SAAOC,MAAK,KAAK,SAAS,UAAU,WAAW;AACjD;AAEA,eAAsB,cACpB,SAC2B;AAC3B,MAAI;AACJ,MAAI;AACF,cAAU,MAAMC,IAAG,QAAQ,aAAa,OAAO,CAAC;AAAA,EAClD,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACA,QAAM,UAA4B,CAAC;AACnC,aAAW,SAAS,SAAS;AAC3B,QAAI,CAAC,MAAM,SAAS,OAAO,EAAG;AAC9B,QAAI;AACF,YAAM,MAAM,MAAMA,IAAG;AAAA,QACnBD,MAAK,KAAK,aAAa,OAAO,GAAG,KAAK;AAAA,QACtC;AAAA,MACF;AACA,YAAM,SAAS,KAAK,MAAM,GAAG;AAG7B,UAAI,OAAO,YAAY,KAAK,CAAC,OAAO,MAAM,CAAC,OAAO,SAAS,CAAC,OAAO,MAAM;AACvE;AAAA,MACF;AACA,cAAQ,KAAK,MAAM;AAAA,IACrB,QAAQ;AACN;AAAA,IACF;AAAA,EACF;AACA,SAAO,QAAQ,KAAK,CAAC,GAAG,MAAM,EAAE,GAAG,cAAc,EAAE,EAAE,CAAC;AACxD;;;ADtDA,eAAsB,qBACpB,SACA,WAC8B;AAC9B,QAAM,eAAeE,MAAK,QAAQ,OAAO;AACzC,QAAM,UAAU,aAAc,MAAM,cAAc,YAAY;AAC9D,QAAM,SAA8B;AAAA,IAClC,kBAAkB;AAAA,IAClB,gBAAgB,QAAQ;AAAA,IACxB,gBAAgB,CAAC;AAAA,EACnB;AAEA,QAAM,OAAO,MAAM,kBAAkB,YAAY;AACjD,QAAM,gBAAgB,oBAAI,IAAgC;AAE1D,aAAW,UAAU,SAAS;AAC5B,QAAI,OAAO,WAAW,YAAY,OAAO,MAAM,WAAW,EAAG;AAC7D,QAAI,OAAO,kBAAkB,KAAM;AAEnC,QAAI,UAAU,cAAc,IAAI,OAAO,aAAa;AACpD,QAAI,YAAY,QAAW;AACzB,YAAM,UAAU,MAAM;AAAA,QACpB;AAAA,QACA,OAAO;AAAA,MACT;AACA,UAAI,YAAY,MAAM;AACpB,kBAAU;AAAA,MACZ,OAAO;AACL,kBAAU,oBAAI,IAAY;AAC1B,mBAAW,UAAU,SAAS;AAC5B,kBAAQ,IAAI,OAAO,IAAI;AACvB,cAAI,OAAO,aAAc,SAAQ,IAAI,OAAO,YAAY;AAAA,QAC1D;AAAA,MACF;AACA,oBAAc,IAAI,OAAO,eAAe,OAAO;AAAA,IACjD;AAEA,QAAI,YAAY,MAAM;AAGpB,aAAO,mBAAmB;AAC1B;AAAA,IACF;AAEA,UAAM,OAAO,OAAO,MAAM,OAAO,CAAC,MAAM,QAAQ,IAAI,CAAC,CAAC;AACtD,QAAI,KAAK,SAAS,GAAG;AACnB,aAAO,eAAe,OAAO,EAAE,IAAI;AAAA,IACrC;AAAA,EACF;AAEA,SAAO;AACT;;;ALpEO,IAAM,QAAQ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+BrB,SAAS,UAAU,MAA4B;AAC7C,QAAM,SAAqB;AAAA,IACzB,KAAK,QAAQ,IAAI;AAAA,IACjB,MAAM;AAAA,IACN,eAAe;AAAA,IACf,MAAM;AAAA,EACR;AACA,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,UAAM,MAAM,KAAK,CAAC;AAClB,QAAI,QAAQ,UAAU;AACpB,aAAO,OAAO;AAAA,IAChB,WAAW,QAAQ,oBAAoB;AACrC,aAAO,gBAAgB;AAAA,IACzB,WAAW,QAAQ,YAAY,QAAQ,MAAM;AAC3C,aAAO,OAAO;AAAA,IAChB,WAAW,QAAQ,SAAS;AAC1B,YAAM,QAAQ,KAAK,EAAE,CAAC;AACtB,UAAI,CAAC,MAAO,OAAM,IAAI,MAAM,gCAAgC;AAC5D,aAAO,MAAM;AAAA,IACf,WAAW,CAAC,IAAI,WAAW,GAAG,KAAK,OAAO,QAAQ,QAAQ,IAAI,GAAG;AAC/D,aAAO,MAAM;AAAA,IACf,OAAO;AACL,YAAM,IAAI,MAAM,qBAAqB,GAAG,EAAE;AAAA,IAC5C;AAAA,EACF;AACA,SAAO;AACT;AAEO,SAAS,mBAAmB,QAA6B;AAC9D,MAAI,CAAC,OAAO,OAAO;AACjB,WAAO,mCAAmC,OAAO,SAAS,MAAM,GAAG,CAAC,CAAC;AAAA,EACvE;AAEA,QAAM,QAAkB,CAAC;AACzB,QAAM,SACJ,OAAO,kBAAkB,OACrB,GAAG,OAAO,aAAa,UAAU,OAAO,kBAAkB,IAAI,KAAK,GAAG,iBACtE;AACN,QAAM,KAAK,+BAA0B,MAAM,GAAG;AAE9C,MAAI,CAAC,OAAO,kBAAkB;AAC5B,UAAM;AAAA,MACJ;AAAA,IACF;AAAA,EACF;AAEA,QAAM,gBAAgB,OAAO,KAAK,OAAO,aAAa;AACtD,QAAM,aAAa,OAAO,KAAK,OAAO,UAAU;AAChD,MAAI,cAAc,SAAS,GAAG;AAC5B,UAAM;AAAA,MACJ,mBAAmB,cAAc,MAAM,IAAI,OAAO,aAAa,MAAM,cAAc,KAAK,IAAI,CAAC;AAAA,IAC/F;AAAA,EACF;AACA,MAAI,WAAW,SAAS,GAAG;AACzB,UAAM;AAAA,MACJ,gBAAgB,WAAW,MAAM,IAAI,OAAO,UAAU,MAAM,WAAW,KAAK,IAAI,CAAC;AAAA,IACnF;AAAA,EACF;AACA,MAAI,OAAO,cAAc,SAAS,GAAG;AACnC,UAAM;AAAA,MACJ,uBAAuB,OAAO,cAAc,MAAM,MAAM,OAAO,cAAc,KAAK,IAAI,CAAC;AAAA,IACzF;AAAA,EACF;AACA,MAAI,OAAO,WAAW,SAAS,GAAG;AAChC,UAAM;AAAA,MACJ,0CAAqC,OAAO,WAAW,MAAM,MAAM,OAAO,WAAW,KAAK,IAAI,CAAC;AAAA,IACjG;AAAA,EACF;AACA,QAAM,KAAK,mBAAmB,OAAO,cAAc,EAAE;AACrD,SAAO,MAAM,KAAK,IAAI;AACxB;AASO,SAAS,oBACd,QACA,eACQ;AACR,QAAM,QAAkB,CAAC;AACzB,QAAM;AAAA,IACJ;AAAA,EACF;AACA,QAAM,KAAK,EAAE;AACb,QAAM,KAAK,0DAA0D;AACrE,QAAM;AAAA,IACJ,KAAK;AAAA,MACH;AAAA,QACE,eAAe,OAAO;AAAA,QACtB,gBAAgB,OAAO;AAAA,QACvB,eAAe,OAAO;AAAA,QACtB,YAAY,OAAO;AAAA,QACnB,cAAc,OAAO;AAAA,QACrB,eAAe,OAAO;AAAA,QACtB,YAAY,OAAO;AAAA,QACnB,SAAS,OAAO;AAAA,MAClB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,QAAM,KAAK,EAAE;AAEb,QAAM,cAAc,CAAC,GAAG,OAAO,cAAc,GAAG,OAAO,aAAa;AACpE,MAAI,CAAC,OAAO,oBAAoB,OAAO,mBAAmB,gBAAgB;AACxE,UAAM;AAAA,MACJ;AAAA,IACF;AAAA,EACF,OAAO;AACL,UAAM;AAAA,MACJ;AAAA,IACF;AACA,UAAM,KAAK,EAAE;AACb,UAAM,KAAK,UAAU,KAAK,UAAU,WAAW,CAAC,EAAE;AAAA,EACpD;AAEA,QAAM,mBAAmB,OAAO,KAAK,cAAc,cAAc;AACjE,MAAI,iBAAiB,SAAS,GAAG;AAC/B,UAAM,KAAK,EAAE;AACb,UAAM;AAAA,MACJ,oBAAoB,iBAAiB,KAAK,IAAI,CAAC;AAAA,IACjD;AAAA,EACF;AAEA,QAAM,KAAK,EAAE;AACb,QAAM;AAAA,IACJ;AAAA,EACF;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;AAEA,eAAsB,YACpB,MACA,KAAiB;AAAA,EACf,KAAK,CAAC,SAAS,QAAQ,OAAO,MAAM,GAAG,IAAI;AAAA,CAAI;AAAA,EAC/C,KAAK,CAAC,SAAS,QAAQ,OAAO,MAAM,GAAG,IAAI;AAAA,CAAI;AACjD,GACiB;AACjB,MAAI;AACJ,MAAI;AACF,WAAO,UAAU,IAAI;AACrB,QAAI,KAAK,QAAQ,KAAK,eAAe;AACnC,YAAM,IAAI,MAAM,oDAAoD;AAAA,IACtE;AAAA,EACF,SAAS,OAAO;AACd,OAAG,IAAI,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,CAAC;AAC7D,OAAG,IAAI,KAAK;AACZ,WAAO;AAAA,EACT;AAEA,MAAI,KAAK,MAAM;AACb,OAAG,IAAI,KAAK;AACZ,WAAO;AAAA,EACT;AAEA,QAAM,UAAUC,MAAK,QAAQ,KAAK,GAAG;AACrC,QAAM,SAAS,MAAM,aAAa,OAAO;AAEzC,MAAI,CAAC,QAAQ;AACX,OAAG;AAAA,MACD,8BAA8BA,MAAK,KAAK,SAAS,UAAU,eAAe,CAAC;AAAA,IAC7E;AACA,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,aAAa,WAAW;AACjC,OAAG;AAAA,MACD,mCAAmC,OAAO;AAAA,IAC5C;AACA,WAAO;AAAA,EACT;AAIA,QAAM,gBAAgB,MAAM,qBAAqB,OAAO;AAExD,MAAI,KAAK,eAAe;AACtB,OAAG;AAAA,MACD,OAAO,QACH,oBAAoB,QAAQ,aAAa,IACzC,mBAAmB,MAAM;AAAA,IAC/B;AACA,WAAO,OAAO,QAAQ,IAAI;AAAA,EAC5B;AAEA,MAAI,KAAK,MAAM;AACb,UAAM,SAA4D;AAClE,QAAI,cAAc,iBAAiB,GAAG;AACpC,aAAO,YAAY;AAAA,IACrB;AACA,OAAG,IAAI,KAAK,UAAU,QAAQ,MAAM,CAAC,CAAC;AAAA,EACxC,OAAO;AACL,UAAM,QAAQ,CAAC,mBAAmB,MAAM,CAAC;AACzC,UAAM,WAAW,OAAO,KAAK,cAAc,cAAc;AACzD,QAAI,SAAS,SAAS,GAAG;AACvB,YAAM;AAAA,QACJ,mCAAmC,SAAS,MAAM,IAAI,cAAc,cAAc,MAAM,SAAS,KAAK,IAAI,CAAC;AAAA,MAC7G;AAAA,IACF;AACA,OAAG,IAAI,MAAM,KAAK,IAAI,CAAC;AAAA,EACzB;AACA,SAAO,OAAO,QAAQ,IAAI;AAC5B;;;AQjPA,YAAY,QAAQ,KAAK,MAAM,CAAC,CAAC,EAAE;AAAA,EACjC,CAAC,SAAS,QAAQ,KAAK,IAAI;AAAA,EAC3B,CAAC,QAAQ;AACP,YAAQ,OAAO,MAAM,sBAAsB,GAAG;AAAA,CAAI;AAClD,YAAQ,KAAK,CAAC;AAAA,EAChB;AACF;","names":["path","fs","path","execFile","promisify","fs","path","execFile","promisify","fg","path","fg","exec","promisify","execFile","path","fs","exec","fg","exec","promisify","execFile","fs","path","path","fs","path","path","path","fs","path","path"]}
|
|
1
|
+
{"version":3,"sources":["../src/drift/cli.ts","../src/drift/drift.ts","../src/snapshot/snapshot.ts","../src/mcp/sampler.ts","../src/test-map.ts","../src/decisions/drift.ts","../src/decisions/decisions.ts","../src/context/lexical.ts","../bin/mason-drift.ts"],"sourcesContent":["import path from \"node:path\";\nimport { computeDrift } from \"./drift.js\";\nimport type { DriftReport } from \"./drift.js\";\nimport { computeDecisionDrift } from \"../decisions/drift.js\";\nimport type { DecisionDriftReport } from \"../decisions/drift.js\";\n\nexport const USAGE = `Usage: mason-drift [--dir <path>] [--json | --refresh-prompt]\n\nChecks the Mason concept map (.mason/snapshot.json) against git HEAD.\nDeterministic: no LLM call, no network — safe for CI.\n\nOptions:\n --dir <path> Project root to check (default: current directory)\n --json Print the full drift report as JSON\n --refresh-prompt When stale, print refresh instructions for ANY coding\n assistant with the Mason MCP server connected (Claude,\n Codex, Gemini, ...) — pipe it to your agent CLI to\n close the loop. Prints nothing extra when fresh.\n --help Show this help\n\nExit codes:\n 0 concept map is up to date\n 1 concept map is stale\n 2 error (no snapshot, not a git repository, bad arguments)`;\n\nexport interface DriftCliIo {\n out: (line: string) => void;\n err: (line: string) => void;\n}\n\ninterface ParsedArgs {\n dir: string;\n json: boolean;\n refreshPrompt: boolean;\n help: boolean;\n}\n\nfunction parseArgs(argv: string[]): ParsedArgs {\n const parsed: ParsedArgs = {\n dir: process.cwd(),\n json: false,\n refreshPrompt: false,\n help: false,\n };\n for (let i = 0; i < argv.length; i++) {\n const arg = argv[i];\n if (arg === \"--json\") {\n parsed.json = true;\n } else if (arg === \"--refresh-prompt\") {\n parsed.refreshPrompt = true;\n } else if (arg === \"--help\" || arg === \"-h\") {\n parsed.help = true;\n } else if (arg === \"--dir\") {\n const value = argv[++i];\n if (!value) throw new Error(\"--dir requires a path argument\");\n parsed.dir = value;\n } else if (!arg.startsWith(\"-\") && parsed.dir === process.cwd()) {\n parsed.dir = arg;\n } else {\n throw new Error(`Unknown argument: ${arg}`);\n }\n }\n return parsed;\n}\n\nexport function formatDriftSummary(report: DriftReport): string {\n if (!report.stale) {\n return `Concept map is up to date (HEAD ${report.headHash.slice(0, 7)}).`;\n }\n\n const lines: string[] = [];\n const behind =\n report.commitsBehind !== null\n ? `${report.commitsBehind} commit${report.commitsBehind === 1 ? \"\" : \"s\"} behind HEAD`\n : \"an unknown number of commits behind HEAD\";\n lines.push(`Concept map is STALE — ${behind}.`);\n\n if (!report.historyAvailable) {\n lines.push(\n \"The snapshot's base commit is unreachable (shallow clone or rewritten history); per-feature drift could not be computed.\"\n );\n }\n\n const staleFeatures = Object.keys(report.staleFeatures);\n const staleFlows = Object.keys(report.staleFlows);\n if (staleFeatures.length > 0) {\n lines.push(\n `Stale features (${staleFeatures.length}/${report.totalFeatures}): ${staleFeatures.join(\", \")}`\n );\n }\n if (staleFlows.length > 0) {\n lines.push(\n `Stale flows (${staleFlows.length}/${report.totalFlows}): ${staleFlows.join(\", \")}`\n );\n }\n if (report.unmappedFiles.length > 0) {\n lines.push(\n `Unmapped new files (${report.unmappedFiles.length}): ${report.unmappedFiles.join(\", \")}`\n );\n }\n if (report.ghostFiles.length > 0) {\n lines.push(\n `Ghost files — mapped but deleted (${report.ghostFiles.length}): ${report.ghostFiles.join(\", \")}`\n );\n }\n lines.push(`Recommendation: ${report.recommendation}`);\n return lines.join(\"\\n\");\n}\n\n/**\n * Provider-neutral refresh instructions: any coding assistant with the\n * Mason MCP server connected can execute them — no Claude/Codex/Gemini\n * assumptions. This is the automation half of \"the map maintains itself\":\n * CI detects with this binary (free, deterministic), then pipes this\n * prompt to whatever headless agent the team runs.\n */\nexport function formatRefreshPrompt(\n report: DriftReport,\n decisionDrift: DecisionDriftReport\n): string {\n const lines: string[] = [];\n lines.push(\n \"The Mason concept map for this project is stale. Refresh it using the Mason MCP tools (server name: mason). Work autonomously; do not ask questions. Modify ONLY the concept map via Mason tools — do not edit source files.\"\n );\n lines.push(\"\");\n lines.push(\"DRIFT REPORT (deterministic, computed against git HEAD):\");\n lines.push(\n JSON.stringify(\n {\n commitsBehind: report.commitsBehind,\n recommendation: report.recommendation,\n staleFeatures: report.staleFeatures,\n staleFlows: report.staleFlows,\n changedFiles: report.changedFiles,\n unmappedFiles: report.unmappedFiles,\n ghostFiles: report.ghostFiles,\n renames: report.renames,\n },\n null,\n 2\n )\n );\n lines.push(\"\");\n\n const scopedFiles = [...report.changedFiles, ...report.unmappedFiles];\n if (!report.historyAvailable || report.recommendation === \"full-rebuild\") {\n lines.push(\n \"PROCEDURE (full rebuild): run the complete Map-Reduce build. Call generate_snapshot_batch repeatedly (follow nextOffset until null), calling save_partial_snapshot after each batch, then reduce_snapshot, then save_snapshot once with the unified map. Derive features ONLY from files shown in each batch prompt — never invent paths.\"\n );\n } else {\n lines.push(\n \"PROCEDURE (scoped refresh): call generate_snapshot_batch with the files list below — the SAME list on every call — following nextOffset until null, calling save_partial_snapshot after each batch. Then call reduce_snapshot (it merges into the existing map, preserving untouched entries) and save_snapshot once. Use save_snapshot's removeFeatures/removeFlows for features that no longer exist (see ghostFiles/renames). Derive features ONLY from files shown in each batch prompt — never invent paths.\"\n );\n lines.push(\"\");\n lines.push(`files: ${JSON.stringify(scopedFiles)}`);\n }\n\n const staleDecisionIds = Object.keys(decisionDrift.staleDecisions);\n if (staleDecisionIds.length > 0) {\n lines.push(\"\");\n lines.push(\n `NOTE: decisions [${staleDecisionIds.join(\", \")}] have anchor files that changed. Do NOT modify decision records in this automated run — they encode human knowledge. Mention them in your final summary so the team re-verifies them.`\n );\n }\n\n lines.push(\"\");\n lines.push(\n \"Finish by confirming the map was saved and summarizing which entries changed.\"\n );\n return lines.join(\"\\n\");\n}\n\nexport async function runDriftCli(\n argv: string[],\n io: DriftCliIo = {\n out: (line) => process.stdout.write(`${line}\\n`),\n err: (line) => process.stderr.write(`${line}\\n`),\n }\n): Promise<number> {\n let args: ParsedArgs;\n try {\n args = parseArgs(argv);\n if (args.json && args.refreshPrompt) {\n throw new Error(\"--json and --refresh-prompt are mutually exclusive\");\n }\n } catch (error) {\n io.err(error instanceof Error ? error.message : String(error));\n io.err(USAGE);\n return 2;\n }\n\n if (args.help) {\n io.out(USAGE);\n return 0;\n }\n\n const rootDir = path.resolve(args.dir);\n const report = await computeDrift(rootDir);\n\n if (!report) {\n io.err(\n `No Mason snapshot found at ${path.join(rootDir, \".mason\", \"snapshot.json\")}. Build one via the Mason MCP server first.`\n );\n return 2;\n }\n\n if (report.headHash === \"unknown\") {\n io.err(\n `Could not determine git HEAD in ${rootDir} — not a git repository, or git is unavailable.`\n );\n return 2;\n }\n\n // Additive: decision staleness never changes exit codes — `stale` and the\n // 0/1/2 contract keep meaning MAP staleness for existing CI consumers.\n const decisionDrift = await computeDecisionDrift(rootDir);\n\n if (args.refreshPrompt) {\n io.out(\n report.stale\n ? formatRefreshPrompt(report, decisionDrift)\n : formatDriftSummary(report)\n );\n return report.stale ? 1 : 0;\n }\n\n if (args.json) {\n const output: DriftReport & { decisions?: DecisionDriftReport } = report;\n if (decisionDrift.totalDecisions > 0) {\n output.decisions = decisionDrift;\n }\n io.out(JSON.stringify(output, null, 2));\n } else {\n const lines = [formatDriftSummary(report)];\n const staleIds = Object.keys(decisionDrift.staleDecisions);\n if (staleIds.length > 0) {\n lines.push(\n `Decisions needing verification (${staleIds.length}/${decisionDrift.totalDecisions}): ${staleIds.join(\", \")}`\n );\n }\n io.out(lines.join(\"\\n\"));\n }\n return report.stale ? 1 : 0;\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 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\";\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 { 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 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 { runDriftCli } from \"../src/drift/cli.js\";\n\nrunDriftCli(process.argv.slice(2)).then(\n (code) => process.exit(code),\n (err) => {\n process.stderr.write(`mason-drift error: ${err}\\n`);\n process.exit(2);\n }\n);\n"],"mappings":"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","names":["path","fs","path","execFile","promisify","fs","path","execFile","promisify","fg","path","fg","exec","promisify","execFile","path","fs","exec","fg","exec","promisify","execFile","fs","path","path","fs","path","path","path","fs","path","path"]}
|
package/dist/mason-mcp.js
CHANGED
|
@@ -1269,6 +1269,7 @@ async function getReferences(rootDir, targetFiles) {
|
|
|
1269
1269
|
const targetSet = new Set(targetFiles);
|
|
1270
1270
|
const filesToSearch = allSourceFiles.filter((f) => !targetSet.has(f));
|
|
1271
1271
|
const results = /* @__PURE__ */ new Map();
|
|
1272
|
+
const importLine = /^\s*(import\b|from\b.*\bimport\b|const\b.*=\s*require\(|use\b|#include\b|require\s*\()/;
|
|
1272
1273
|
const batchSize = 50;
|
|
1273
1274
|
for (let i = 0; i < filesToSearch.length; i += batchSize) {
|
|
1274
1275
|
const batch = filesToSearch.slice(i, i + batchSize);
|
|
@@ -1279,11 +1280,17 @@ async function getReferences(rootDir, targetFiles) {
|
|
|
1279
1280
|
path9.join(rootDir, file),
|
|
1280
1281
|
"utf-8"
|
|
1281
1282
|
);
|
|
1283
|
+
const lines = content.split("\n");
|
|
1282
1284
|
for (const name of searchNames) {
|
|
1283
1285
|
const regex = new RegExp(`\\b${escapeRegex(name)}\\b`);
|
|
1284
|
-
if (regex.test(content))
|
|
1285
|
-
|
|
1286
|
-
results.
|
|
1286
|
+
if (!regex.test(content)) continue;
|
|
1287
|
+
if (!results.has(file)) {
|
|
1288
|
+
results.set(file, { matches: /* @__PURE__ */ new Set(), isImport: false });
|
|
1289
|
+
}
|
|
1290
|
+
const entry = results.get(file);
|
|
1291
|
+
entry.matches.add(name);
|
|
1292
|
+
if (!entry.isImport && lines.some((l) => regex.test(l) && importLine.test(l))) {
|
|
1293
|
+
entry.isImport = true;
|
|
1287
1294
|
}
|
|
1288
1295
|
}
|
|
1289
1296
|
} catch {
|
|
@@ -1291,10 +1298,14 @@ async function getReferences(rootDir, targetFiles) {
|
|
|
1291
1298
|
})
|
|
1292
1299
|
);
|
|
1293
1300
|
}
|
|
1294
|
-
return [...results.entries()].map(([file, matches]) => ({
|
|
1301
|
+
return [...results.entries()].map(([file, { matches, isImport }]) => ({
|
|
1295
1302
|
file,
|
|
1296
|
-
matches: [...matches]
|
|
1297
|
-
|
|
1303
|
+
matches: [...matches],
|
|
1304
|
+
kind: isImport ? "import" : "mention"
|
|
1305
|
+
})).sort((a, b) => {
|
|
1306
|
+
if (a.kind !== b.kind) return a.kind === "import" ? -1 : 1;
|
|
1307
|
+
return b.matches.length - a.matches.length;
|
|
1308
|
+
});
|
|
1298
1309
|
}
|
|
1299
1310
|
async function getRelatedTests(rootDir, targetFiles) {
|
|
1300
1311
|
const testPatterns = [
|
|
@@ -2320,13 +2331,13 @@ async function exportToConfluence(rootDir, config, options = {}, deps) {
|
|
|
2320
2331
|
const confluence = config.confluence;
|
|
2321
2332
|
if (!confluence) {
|
|
2322
2333
|
throw new Error(
|
|
2323
|
-
|
|
2334
|
+
"No Confluence credentials configured. Ask your assistant to call mason_set_confluence first."
|
|
2324
2335
|
);
|
|
2325
2336
|
}
|
|
2326
2337
|
const snapshot = await loadSnapshot(rootDir);
|
|
2327
2338
|
if (!snapshot) {
|
|
2328
2339
|
throw new Error(
|
|
2329
|
-
|
|
2340
|
+
"No snapshot found. Build the concept map first (ask your assistant to run mason_init and follow the playbook)."
|
|
2330
2341
|
);
|
|
2331
2342
|
}
|
|
2332
2343
|
const client = deps?.client ?? createConfluenceClient(confluence);
|
|
@@ -2931,15 +2942,15 @@ function uninitializedResponse(action) {
|
|
|
2931
2942
|
var CLAUDE_MD_SECTION = `<!-- mason:start -->
|
|
2932
2943
|
## Mason concept map
|
|
2933
2944
|
|
|
2934
|
-
This project has a Mason concept map (\`.mason/snapshot.json\`) served over MCP. Use
|
|
2945
|
+
This project has a Mason concept map (\`.mason/snapshot.json\`) and decision store (\`.mason/decisions/\`) served over MCP. Use them BEFORE grep, glob, or file reads:
|
|
2935
2946
|
|
|
2936
|
-
-
|
|
2937
|
-
-
|
|
2938
|
-
- Before editing any file \u2192
|
|
2939
|
-
- Learned something the code
|
|
2940
|
-
- Decisions returned by \`get_context\` are constraints \u2014 follow them
|
|
2947
|
+
- Task, bug, or change request \u2192 \`get_context\` with the task text: relevant features, files, tests, blast radius, freshness, and decisions in one call.
|
|
2948
|
+
- "How does X work / where is Y" \u2192 \`get_snapshot\` first.
|
|
2949
|
+
- Before editing any file \u2192 \`get_impact\`.
|
|
2950
|
+
- Learned something the code can't tell you (a failed approach, a deprecation, a workaround's reason, a review-settled convention) \u2192 record it with \`save_decision\`. Never record code-derivable facts, session trivia, or secrets.
|
|
2951
|
+
- Decisions returned by \`get_context\` are constraints \u2014 follow them; verify any marked stale before relying on it.
|
|
2941
2952
|
|
|
2942
|
-
Fall back to manual exploration only for
|
|
2953
|
+
Fall back to manual exploration only for what the map doesn't answer.
|
|
2943
2954
|
<!-- mason:end -->`;
|
|
2944
2955
|
var SETUP_PLAYBOOK = `You are walking the user through one-time Mason setup for this project. Mason persists a concept map of this codebase so future questions don't re-explore from scratch. The map is built via a Map-Reduce pattern so it covers the WHOLE codebase, not just a sample. Surface each question to the user in plain language and wait for their answer before proceeding.
|
|
2945
2956
|
|
|
@@ -2994,9 +3005,13 @@ If the credentials are rejected with a 401/403 the tool returns a friendly error
|
|
|
2994
3005
|
PHASE 4 \u2014 Assistant instructions (recommended)
|
|
2995
3006
|
Goal: make sure future assistant sessions actually use the map instead of re-exploring.
|
|
2996
3007
|
|
|
2997
|
-
Tell the user: "Assistants reliably follow project
|
|
3008
|
+
Tell the user: "Assistants reliably follow project instruction files but often ignore available tools. Mason works best if I add a short section to this project's instruction file telling assistants to consult the concept map first. Add it?"
|
|
2998
3009
|
On no: skip to Phase 5.
|
|
2999
|
-
On yes
|
|
3010
|
+
On yes, pick the target file by what the project already uses:
|
|
3011
|
+
- \`AGENTS.md\` exists \u2192 put the section there (it's the tool-agnostic standard). If a \`CLAUDE.md\` also exists and doesn't reference AGENTS.md, add a one-line pointer to it.
|
|
3012
|
+
- only \`CLAUDE.md\` (or \`.claude/CLAUDE.md\`) exists \u2192 put the section there.
|
|
3013
|
+
- neither exists \u2192 create \`CLAUDE.md\` with just the section.
|
|
3014
|
+
Append the following section verbatim; if the \`<!-- mason:start -->\` marker is already present in the target file, replace the marked block instead of appending:
|
|
3000
3015
|
|
|
3001
3016
|
${CLAUDE_MD_SECTION}
|
|
3002
3017
|
|
|
@@ -3153,7 +3168,7 @@ async function getCodeSamples(dir, count = 15) {
|
|
|
3153
3168
|
const rootDir = path14.resolve(dir);
|
|
3154
3169
|
const samples = await sampleFiles(rootDir, count);
|
|
3155
3170
|
const output = {
|
|
3156
|
-
note: "These are previews (first ~60 lines).
|
|
3171
|
+
note: "These are previews (first ~60 lines). Read the file directly with your own tools to see it in full.",
|
|
3157
3172
|
files: samples.map((s) => ({
|
|
3158
3173
|
path: s.path,
|
|
3159
3174
|
reason: s.reason,
|
|
@@ -3260,7 +3275,7 @@ async function getSnapshot(dir) {
|
|
|
3260
3275
|
if (!snapshot) {
|
|
3261
3276
|
return JSON.stringify({
|
|
3262
3277
|
exists: false,
|
|
3263
|
-
hint: "Project is initialized but no concept map exists yet.
|
|
3278
|
+
hint: "Project is initialized but no concept map exists yet. Run mason_init for the setup playbook (generate_snapshot_batch \u2192 save_partial_snapshot per batch, then reduce_snapshot and save_snapshot)."
|
|
3264
3279
|
});
|
|
3265
3280
|
}
|
|
3266
3281
|
const drift = await computeDrift(rootDir);
|
|
@@ -3530,7 +3545,7 @@ async function fullAnalysis(dir) {
|
|
|
3530
3545
|
loadSnapshot(rootDir)
|
|
3531
3546
|
]);
|
|
3532
3547
|
const output = {
|
|
3533
|
-
note: "Full project analysis. Code samples are previews (~60 lines).
|
|
3548
|
+
note: "Full project analysis. Code samples are previews (~60 lines). Read files directly with your own tools to see them in full.",
|
|
3534
3549
|
analysis: JSON.parse(analysis),
|
|
3535
3550
|
structure: JSON.parse(structure),
|
|
3536
3551
|
codeSamples: JSON.parse(samples),
|
|
@@ -3542,7 +3557,7 @@ async function fullAnalysis(dir) {
|
|
|
3542
3557
|
features: snapshot.features,
|
|
3543
3558
|
flows: snapshot.flows
|
|
3544
3559
|
};
|
|
3545
|
-
output.note = "Full project analysis with concept map. The concept map shows which files implement each feature and how data flows through them. Use it to jump straight to relevant files instead of exploring
|
|
3560
|
+
output.note = "Full project analysis with concept map. The concept map shows which files implement each feature and how data flows through them. Use it to jump straight to relevant files instead of exploring, then read them directly with your own tools.";
|
|
3546
3561
|
}
|
|
3547
3562
|
return JSON.stringify(output, null, 2);
|
|
3548
3563
|
}
|
|
@@ -3932,7 +3947,7 @@ function createMcpServer() {
|
|
|
3932
3947
|
const server = new McpServer(
|
|
3933
3948
|
{
|
|
3934
3949
|
name: "mason",
|
|
3935
|
-
version: "0.
|
|
3950
|
+
version: "0.7.0"
|
|
3936
3951
|
},
|
|
3937
3952
|
{
|
|
3938
3953
|
instructions: "Mason maintains a persistent feature-to-file concept map of this codebase so you can skip manual exploration. RULE: when given a task, bug, or change request, call `get_context` with the task text first \u2014 one call returns the relevant features, files, tests, blast radius, and freshness. Before answering ANY question about features, architecture, data flows, or where something lives \u2014 and before any grep/glob/file-read exploration for such a question \u2014 call `get_snapshot` first. One call returns the whole map and replaces 5-10 search round-trips; if it has drifted it says so and self-corrects. Likewise call `get_impact` BEFORE editing or refactoring a file (git co-change history + references + related tests \u2014 signals you cannot get from reading the file itself), and `mason_check_drift` to verify the map is fresh in long sessions. When you learn something the code alone can't tell you \u2014 a failed approach, a deprecation, a workaround's reason, a review-settled convention \u2014 record it with `save_decision` so the whole team's assistants inherit it; `get_context` returns matching decisions as constraints. If `get_snapshot` reports no snapshot exists, offer to set Mason up: `mason_init` returns a setup playbook (a Map-Reduce loop of `generate_snapshot_batch` + `save_partial_snapshot`, then `reduce_snapshot` + `save_snapshot`, optionally `mason_set_confluence`, then `mason_complete_init`). `full_analysis`, `analyze_project`, and `get_code_samples` are read-only diagnostics for unmapped projects and never need init. Mason has no CLI; everything happens through these tools."
|