mason-context 0.3.7 → 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.
@@ -0,0 +1,4283 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/mcp/sampler.ts
13
+ import fs2 from "fs/promises";
14
+ import path from "path";
15
+ import { execFile as execFile3 } from "child_process";
16
+ import { promisify as promisify3 } from "util";
17
+ import fg2 from "fast-glob";
18
+ async function loadProjectConfig(rootDir) {
19
+ try {
20
+ const raw = await fs2.readFile(
21
+ path.join(rootDir, ".mason", "config.json"),
22
+ "utf-8"
23
+ );
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+ async function getTrackedFiles(rootDir) {
30
+ try {
31
+ const { stdout } = await exec3("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
32
+ cwd: rootDir,
33
+ maxBuffer: 1e7
34
+ });
35
+ return new Set(stdout.trim().split("\n").filter(Boolean));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+ async function sampleFiles(rootDir, maxFiles = 25) {
41
+ const selected = /* @__PURE__ */ new Map();
42
+ const projectConfig = await loadProjectConfig(rootDir);
43
+ const ignorePatterns = [...IGNORE_PATTERNS, ...projectConfig.ignore ?? []];
44
+ const trackedFiles = await getTrackedFiles(rootDir);
45
+ for (const filePath of projectConfig.alwaysInclude ?? []) {
46
+ if (selected.size >= maxFiles) break;
47
+ const resolvedPath = path.resolve(rootDir, filePath);
48
+ if (!resolvedPath.startsWith(path.resolve(rootDir))) continue;
49
+ selected.set(filePath, "always-include (project config)");
50
+ }
51
+ let configCount = 0;
52
+ for (const pattern of CONFIG_FILES) {
53
+ if (configCount >= 5) break;
54
+ const matches = await fg2(pattern, {
55
+ cwd: rootDir,
56
+ ignore: ignorePatterns,
57
+ deep: 3
58
+ });
59
+ for (const match of matches) {
60
+ if (configCount >= 5 || selected.size >= maxFiles) break;
61
+ selected.set(match, "config file");
62
+ configCount++;
63
+ }
64
+ }
65
+ const moduleBuildPatterns = [
66
+ // Gradle
67
+ "**/build.gradle.kts",
68
+ "**/build.gradle",
69
+ // Cargo workspace members
70
+ "**/Cargo.toml",
71
+ // Node workspaces
72
+ "**/package.json",
73
+ // Go sub-modules
74
+ "**/go.mod"
75
+ ];
76
+ let moduleBuildCount = 0;
77
+ for (const pattern of moduleBuildPatterns) {
78
+ const matches = await fg2(pattern, {
79
+ cwd: rootDir,
80
+ ignore: ignorePatterns,
81
+ deep: 4
82
+ });
83
+ const subMatches = matches.filter((m) => m.includes("/"));
84
+ for (const match of subMatches) {
85
+ if (moduleBuildCount >= 4 || selected.size >= maxFiles) break;
86
+ if (!selected.has(match)) {
87
+ selected.set(match, "module build file (reveals dependency graph)");
88
+ moduleBuildCount++;
89
+ }
90
+ }
91
+ if (moduleBuildCount >= 4) break;
92
+ }
93
+ let entryCount = 0;
94
+ for (const pattern of ENTRY_POINT_PATTERNS) {
95
+ if (entryCount >= 2) break;
96
+ const matches = await fg2(pattern, {
97
+ cwd: rootDir,
98
+ ignore: ignorePatterns,
99
+ deep: 5
100
+ });
101
+ for (const match of matches) {
102
+ if (entryCount >= 2 || selected.size >= maxFiles) break;
103
+ if (!selected.has(match)) {
104
+ selected.set(match, "entry point");
105
+ entryCount++;
106
+ }
107
+ }
108
+ }
109
+ try {
110
+ const { stdout } = await exec3(
111
+ "git",
112
+ ["log", "--since=3 months ago", "--format=", "--name-only"],
113
+ { cwd: rootDir, maxBuffer: 5e6 }
114
+ );
115
+ const fileCounts = /* @__PURE__ */ new Map();
116
+ for (const line of stdout.split("\n")) {
117
+ if (!line) continue;
118
+ if (line.includes("node_modules") || line.includes("/build/") || line.includes(".gradle") || line.includes("/generated/"))
119
+ continue;
120
+ const ext = path.extname(line).slice(1);
121
+ if (!SOURCE_EXTENSIONS.includes(ext)) continue;
122
+ fileCounts.set(line, (fileCounts.get(line) ?? 0) + 1);
123
+ }
124
+ const hotFiles = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
125
+ for (const [file, count] of hotFiles) {
126
+ if (selected.size >= maxFiles) break;
127
+ if (!selected.has(file)) {
128
+ selected.set(file, `frequently changed (${count} commits in 3 months)`);
129
+ }
130
+ }
131
+ } catch {
132
+ }
133
+ const seenCategories = /* @__PURE__ */ new Set();
134
+ let patternCount = 0;
135
+ for (const pattern of ARCHITECTURAL_PATTERNS) {
136
+ if (patternCount >= 8 || selected.size >= maxFiles) break;
137
+ if (seenCategories.has(pattern.category)) continue;
138
+ const matches = await fg2(pattern.glob, {
139
+ cwd: rootDir,
140
+ ignore: ignorePatterns
141
+ });
142
+ if (matches.length > 0) {
143
+ for (const match of matches) {
144
+ if (!selected.has(match)) {
145
+ selected.set(match, pattern.reason);
146
+ seenCategories.add(pattern.category);
147
+ patternCount++;
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ }
153
+ for (const customGlob of projectConfig.patterns ?? []) {
154
+ if (selected.size >= maxFiles) break;
155
+ const matches = await fg2(customGlob, {
156
+ cwd: rootDir,
157
+ ignore: ignorePatterns
158
+ });
159
+ for (const match of matches) {
160
+ if (selected.size >= maxFiles) break;
161
+ if (!selected.has(match)) {
162
+ selected.set(match, "custom pattern (project config)");
163
+ break;
164
+ }
165
+ }
166
+ }
167
+ const testPatternGroups = [
168
+ // JS/TS tests
169
+ { patterns: ["**/*.test.*", "**/*.spec.*"], label: "JS/TS test" },
170
+ // JVM tests
171
+ { patterns: ["**/*Test.kt", "**/*Test.java"], label: "JVM test" },
172
+ // Python tests
173
+ { patterns: ["**/test_*.py", "**/*_test.py"], label: "Python test" },
174
+ // Go tests
175
+ { patterns: ["**/*_test.go"], label: "Go test" },
176
+ // Swift tests
177
+ { patterns: ["**/*Tests.swift", "**/*Test.swift"], label: "Swift test" },
178
+ // Rust tests
179
+ { patterns: ["**/*_test.rs"], label: "Rust test" }
180
+ ];
181
+ let testCount = 0;
182
+ for (const group of testPatternGroups) {
183
+ if (testCount >= 3 || selected.size >= maxFiles) break;
184
+ const testFiles = await fg2(group.patterns, {
185
+ cwd: rootDir,
186
+ ignore: ignorePatterns
187
+ });
188
+ if (testFiles.length > 0) {
189
+ for (const file of testFiles) {
190
+ if (!selected.has(file)) {
191
+ selected.set(file, `test example (${group.label})`);
192
+ testCount++;
193
+ break;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ const sourceGlobs = SOURCE_EXTENSIONS.map((ext) => `**/*.${ext}`);
199
+ const allSourceFiles = await fg2(sourceGlobs, {
200
+ cwd: rootDir,
201
+ ignore: ignorePatterns
202
+ });
203
+ const dirRepresentatives = /* @__PURE__ */ new Map();
204
+ const boringFiles = /\.(gradle|gradle\.kts|json|toml|yaml|yml|xml|properties)$/;
205
+ for (const file of allSourceFiles) {
206
+ const topDir = file.split("/")[0];
207
+ if (!dirRepresentatives.has(topDir) && !boringFiles.test(file)) {
208
+ dirRepresentatives.set(topDir, file);
209
+ }
210
+ }
211
+ for (const [, file] of dirRepresentatives) {
212
+ if (selected.size >= maxFiles) break;
213
+ if (!selected.has(file)) {
214
+ selected.set(file, "directory representative");
215
+ }
216
+ }
217
+ const results = [];
218
+ for (const [filePath, reason] of selected) {
219
+ try {
220
+ const fullPath = path.resolve(rootDir, filePath);
221
+ if (!fullPath.startsWith(path.resolve(rootDir))) continue;
222
+ if (isSensitiveFile(filePath)) continue;
223
+ if (trackedFiles && !trackedFiles.has(filePath)) continue;
224
+ const stat = await fs2.stat(fullPath);
225
+ if (stat.size > 1e5) continue;
226
+ const content = await fs2.readFile(fullPath, "utf-8");
227
+ const lines = content.split("\n");
228
+ const preview = lines.slice(0, PREVIEW_LINES).join("\n");
229
+ results.push({
230
+ path: filePath,
231
+ preview,
232
+ totalLines: lines.length,
233
+ sizeBytes: stat.size,
234
+ reason
235
+ });
236
+ } catch {
237
+ }
238
+ }
239
+ return results;
240
+ }
241
+ function isSensitiveFile(filePath) {
242
+ const basename = path.basename(filePath);
243
+ return SENSITIVE_PATTERNS.some((p) => p.test(basename));
244
+ }
245
+ async function readFullFile(rootDir, filePath) {
246
+ try {
247
+ const fullPath = path.join(path.resolve(rootDir), filePath);
248
+ if (!fullPath.startsWith(path.resolve(rootDir))) return null;
249
+ if (isSensitiveFile(filePath)) return null;
250
+ const content = await fs2.readFile(fullPath, "utf-8");
251
+ return {
252
+ path: filePath,
253
+ content,
254
+ totalLines: content.split("\n").length
255
+ };
256
+ } catch {
257
+ return null;
258
+ }
259
+ }
260
+ var exec3, SOURCE_EXTENSIONS, CONFIG_FILES, ENTRY_POINT_PATTERNS, ARCHITECTURAL_PATTERNS, IGNORE_PATTERNS, PREVIEW_LINES, SENSITIVE_PATTERNS;
261
+ var init_sampler = __esm({
262
+ "src/mcp/sampler.ts"() {
263
+ "use strict";
264
+ exec3 = promisify3(execFile3);
265
+ SOURCE_EXTENSIONS = [
266
+ "ts",
267
+ "tsx",
268
+ "js",
269
+ "jsx",
270
+ "mts",
271
+ "mjs",
272
+ "kt",
273
+ "kts",
274
+ "java",
275
+ "py",
276
+ "go",
277
+ "rs",
278
+ "swift",
279
+ "rb",
280
+ "cs",
281
+ "cpp",
282
+ "c",
283
+ "h",
284
+ "dart"
285
+ ];
286
+ CONFIG_FILES = [
287
+ // Build & project config
288
+ "package.json",
289
+ "tsconfig.json",
290
+ "build.gradle.kts",
291
+ "build.gradle",
292
+ "settings.gradle.kts",
293
+ "settings.gradle",
294
+ "Cargo.toml",
295
+ "go.mod",
296
+ "pyproject.toml",
297
+ "Gemfile",
298
+ "*.csproj",
299
+ // Version catalogs & dependency locks
300
+ "gradle/libs.versions.toml",
301
+ // Code quality & formatting
302
+ ".editorconfig",
303
+ ".eslintrc.*",
304
+ "eslint.config.*",
305
+ ".prettierrc",
306
+ "rustfmt.toml",
307
+ ".swiftlint.yml",
308
+ // CI/CD
309
+ ".github/workflows/*.yml",
310
+ ".gitlab-ci.yml",
311
+ "Jenkinsfile",
312
+ // Containerization
313
+ "Dockerfile",
314
+ "docker-compose.yml",
315
+ "docker-compose.yaml"
316
+ ];
317
+ ENTRY_POINT_PATTERNS = [
318
+ "src/main.*",
319
+ "src/index.*",
320
+ "src/app.*",
321
+ "main.*",
322
+ "index.*",
323
+ "app.*",
324
+ "App.*",
325
+ "**/Main.kt",
326
+ "**/Application.kt",
327
+ "**/main.py",
328
+ "**/main.go",
329
+ "**/main.rs",
330
+ "**/lib.rs",
331
+ "**/Program.cs"
332
+ ];
333
+ ARCHITECTURAL_PATTERNS = [
334
+ // State/data flow
335
+ { glob: "**/*ViewModel.*", category: "state", reason: "viewmodel (state management)" },
336
+ { glob: "**/*Store.*", category: "state", reason: "store (state management)" },
337
+ { glob: "**/*Reducer.*", category: "state", reason: "reducer (state management)" },
338
+ // Data layer — interface
339
+ { glob: "**/*Repository.*", category: "data-interface", reason: "repository interface (data layer contract)" },
340
+ { glob: "**/*Dao.*", category: "data-interface", reason: "DAO (data access)" },
341
+ { glob: "**/*DataSource.*", category: "data-interface", reason: "data source" },
342
+ // Data layer — implementation (where actual patterns live: mappers, retry, IO dispatchers)
343
+ { glob: "**/*RepositoryImpl.*", category: "data-impl", reason: "repository implementation (data layer patterns)" },
344
+ { glob: "**/*ServiceImpl.*", category: "data-impl", reason: "service implementation" },
345
+ { glob: "**/*Impl.*", category: "data-impl", reason: "implementation (concrete patterns)" },
346
+ // Data transformation
347
+ { glob: "**/*Mapper.*", category: "transform", reason: "mapper (data transformation)" },
348
+ { glob: "**/*Converter.*", category: "transform", reason: "converter (data transformation)" },
349
+ { glob: "**/*Adapter.*", category: "transform", reason: "adapter (interface adaptation)" },
350
+ // Dependency injection / wiring
351
+ { glob: "**/*Module.*", category: "di", reason: "module (DI/wiring)" },
352
+ { glob: "**/*Provider.*", category: "di", reason: "provider (DI/wiring)" },
353
+ { glob: "**/*Container.*", category: "di", reason: "container (DI/wiring)" },
354
+ { glob: "**/*Factory.*", category: "di", reason: "factory (object creation)" },
355
+ // API / network
356
+ { glob: "**/*Service.*", category: "api", reason: "service (business/API layer)" },
357
+ { glob: "**/*Client.*", category: "api", reason: "client (API/network layer)" },
358
+ { glob: "**/*Api.*", category: "api", reason: "API interface definition" },
359
+ // Interface contracts / protocols
360
+ { glob: "**/*Interface.*", category: "contract", reason: "interface definition" },
361
+ { glob: "**/*Protocol.*", category: "contract", reason: "protocol definition" },
362
+ { glob: "**/*Trait.*", category: "contract", reason: "trait definition" },
363
+ // Routing / navigation
364
+ { glob: "**/*Router.*", category: "routing", reason: "router (navigation/routing)" },
365
+ { glob: "**/*Route.*", category: "routing", reason: "route definition" },
366
+ { glob: "**/*NavHost.*", category: "routing", reason: "navigation host" },
367
+ { glob: "**/*Controller.*", category: "routing", reason: "controller (request handling)" },
368
+ { glob: "**/*Handler.*", category: "routing", reason: "handler (request handling)" },
369
+ // Middleware / interceptors
370
+ { glob: "**/*Middleware.*", category: "middleware", reason: "middleware (request pipeline)" },
371
+ { glob: "**/*Interceptor.*", category: "middleware", reason: "interceptor (cross-cutting)" },
372
+ { glob: "**/*Plugin.*", category: "middleware", reason: "plugin (extensibility)" },
373
+ // Models / types
374
+ { glob: "**/*Model.*", category: "model", reason: "model (domain types)" },
375
+ { glob: "**/*Entity.*", category: "model", reason: "entity (persistence types)" },
376
+ { glob: "**/*Dto.*", category: "model", reason: "DTO (data transfer types)" },
377
+ { glob: "**/*Schema.*", category: "model", reason: "schema (data validation)" },
378
+ // Use cases / commands
379
+ { glob: "**/*UseCase.*", category: "usecase", reason: "use case (business logic)" },
380
+ { glob: "**/*Interactor.*", category: "usecase", reason: "interactor (business logic)" },
381
+ { glob: "**/*Command.*", category: "usecase", reason: "command (CQRS pattern)" }
382
+ ];
383
+ IGNORE_PATTERNS = [
384
+ "**/node_modules/**",
385
+ "**/dist/**",
386
+ "**/build/**",
387
+ "**/.gradle/**",
388
+ "**/target/**",
389
+ "**/.git/**",
390
+ "**/vendor/**",
391
+ "**/__pycache__/**",
392
+ "**/venv/**",
393
+ "**/.venv/**",
394
+ "**/*.min.*",
395
+ "**/*.map",
396
+ "**/package-lock.json",
397
+ "**/yarn.lock",
398
+ "**/pnpm-lock.yaml",
399
+ "**/*.lock",
400
+ "**/*.generated.*",
401
+ "**/generated/**",
402
+ "**/R.java",
403
+ "**/BuildConfig.java"
404
+ ];
405
+ PREVIEW_LINES = 60;
406
+ SENSITIVE_PATTERNS = [
407
+ /^\.env$/,
408
+ /^\.env\./,
409
+ /\.pem$/,
410
+ /\.key$/,
411
+ /\.p12$/,
412
+ /\.pfx$/,
413
+ /\.jks$/,
414
+ /id_rsa/,
415
+ /id_ed25519/,
416
+ /credentials\./,
417
+ /secret/i,
418
+ /\.keystore$/,
419
+ /local\.properties$/
420
+ ];
421
+ }
422
+ });
423
+
424
+ // src/test-map.ts
425
+ var test_map_exports = {};
426
+ __export(test_map_exports, {
427
+ buildTestMap: () => buildTestMap
428
+ });
429
+ import path2 from "path";
430
+ import fg3 from "fast-glob";
431
+ async function buildTestMap(dir) {
432
+ const rootDir = path2.resolve(dir);
433
+ const testPatterns = [
434
+ "**/*.test.*",
435
+ "**/*.spec.*",
436
+ "**/*Test.kt",
437
+ "**/*Test.java",
438
+ "**/*Tests.kt",
439
+ "**/*Tests.java",
440
+ "**/test_*.py",
441
+ "**/*_test.py",
442
+ "**/*_test.go",
443
+ "**/*Tests.swift",
444
+ "**/*Test.swift",
445
+ "**/*_test.rs"
446
+ ];
447
+ const testFiles = await fg3(testPatterns, { cwd: rootDir, ignore: IGNORE });
448
+ const sourceFiles = await fg3(
449
+ "**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,dart}",
450
+ { cwd: rootDir, ignore: IGNORE }
451
+ );
452
+ const sourceByBaseName = /* @__PURE__ */ new Map();
453
+ for (const file of sourceFiles) {
454
+ if (testFiles.includes(file)) continue;
455
+ const baseName = path2.basename(file).replace(/\.[^.]+$/, "");
456
+ const existing = sourceByBaseName.get(baseName) ?? [];
457
+ existing.push(file);
458
+ sourceByBaseName.set(baseName, existing);
459
+ }
460
+ const paired = [];
461
+ const unmatched = [];
462
+ for (const testFile of testFiles) {
463
+ const testBaseName = path2.basename(testFile).replace(/\.[^.]+$/, "");
464
+ const sourceName = testBaseName.replace(/Test$|Tests$|Spec$|\.test$|\.spec$/, "").replace(/^test_|_test$/, "");
465
+ if (!sourceName) {
466
+ unmatched.push(testFile);
467
+ continue;
468
+ }
469
+ const candidates = sourceByBaseName.get(sourceName);
470
+ if (candidates && candidates.length > 0) {
471
+ const testDir = path2.dirname(testFile);
472
+ const bestMatch = candidates.reduce((best, candidate) => {
473
+ const candidateDir = path2.dirname(candidate);
474
+ const bestDir = path2.dirname(best);
475
+ const candidateOverlap = commonSegments(testDir, candidateDir);
476
+ const bestOverlap = commonSegments(testDir, bestDir);
477
+ return candidateOverlap > bestOverlap ? candidate : best;
478
+ });
479
+ paired.push({
480
+ test: testFile,
481
+ source: bestMatch,
482
+ confidence: candidates.length === 1 ? "exact" : "best-guess"
483
+ });
484
+ } else {
485
+ unmatched.push(testFile);
486
+ }
487
+ }
488
+ return { totalTestFiles: testFiles.length, paired, unmatched };
489
+ }
490
+ function commonSegments(pathA, pathB) {
491
+ const segsA = pathA.split("/");
492
+ const segsB = pathB.split("/");
493
+ let count = 0;
494
+ for (let i = 0; i < Math.min(segsA.length, segsB.length); i++) {
495
+ if (segsA[i] === segsB[i]) count++;
496
+ else break;
497
+ }
498
+ return count;
499
+ }
500
+ var IGNORE;
501
+ var init_test_map = __esm({
502
+ "src/test-map.ts"() {
503
+ "use strict";
504
+ IGNORE = [
505
+ "**/node_modules/**",
506
+ "**/dist/**",
507
+ "**/build/**",
508
+ "**/.gradle/**",
509
+ "**/target/**",
510
+ "**/.git/**",
511
+ "**/vendor/**",
512
+ "**/__pycache__/**",
513
+ "**/venv/**",
514
+ "**/.venv/**",
515
+ "**/*.min.*",
516
+ "**/*.map"
517
+ ];
518
+ }
519
+ });
520
+
521
+ // src/snapshot/snapshot.ts
522
+ import fs3 from "fs/promises";
523
+ import path3 from "path";
524
+ import { execFile as execFile4 } from "child_process";
525
+ import { promisify as promisify4 } from "util";
526
+ import fg4 from "fast-glob";
527
+ function normalizeFeatureType(value) {
528
+ return value === "infrastructure" ? "infrastructure" : "capability";
529
+ }
530
+ function snapshotDir(rootDir) {
531
+ return path3.join(rootDir, ".mason");
532
+ }
533
+ function snapshotPath(rootDir) {
534
+ return path3.join(snapshotDir(rootDir), "snapshot.json");
535
+ }
536
+ async function loadSnapshot(rootDir) {
537
+ try {
538
+ const raw = await fs3.readFile(snapshotPath(rootDir), "utf-8");
539
+ const parsed = JSON.parse(raw);
540
+ if (parsed.version !== 2) return null;
541
+ return parsed;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+ async function saveSnapshot(rootDir, snapshot) {
547
+ await fs3.mkdir(snapshotDir(rootDir), { recursive: true });
548
+ await fs3.writeFile(
549
+ snapshotPath(rootDir),
550
+ JSON.stringify(snapshot, null, 2),
551
+ "utf-8"
552
+ );
553
+ }
554
+ async function getCurrentGitHash(rootDir) {
555
+ try {
556
+ const { stdout } = await exec4("git", ["rev-parse", "HEAD"], {
557
+ cwd: rootDir
558
+ });
559
+ return stdout.trim();
560
+ } catch {
561
+ return "unknown";
562
+ }
563
+ }
564
+ async function listSourceFiles(resolvedRoot) {
565
+ const all = await fg4(SOURCE_GLOB, {
566
+ cwd: resolvedRoot,
567
+ ignore: SOURCE_IGNORE
568
+ });
569
+ return [...all].sort();
570
+ }
571
+ async function prepareSnapshotBatch(rootDir, offset, batchSize = DEFAULT_BATCH_SIZE, scopeFiles) {
572
+ const resolvedRoot = path3.resolve(rootDir);
573
+ let allFiles = await listSourceFiles(resolvedRoot);
574
+ if (scopeFiles) {
575
+ const scopeSet = new Set(scopeFiles);
576
+ allFiles = allFiles.filter((f) => scopeSet.has(f));
577
+ }
578
+ const totalFiles = allFiles.length;
579
+ const safeOffset = Math.max(0, Math.min(offset, totalFiles));
580
+ const batchPaths = allFiles.slice(safeOffset, safeOffset + batchSize);
581
+ const skeletons = [];
582
+ for (const filePath of batchPaths) {
583
+ const full = await readFullFile(resolvedRoot, filePath);
584
+ if (full) {
585
+ skeletons.push({
586
+ path: full.path,
587
+ content: full.content.slice(0, SKELETON_CHARS)
588
+ });
589
+ }
590
+ }
591
+ const samples = [];
592
+ if (skeletons.length > 0) {
593
+ const step = Math.max(1, Math.floor(skeletons.length / DEEP_SAMPLES_PER_BATCH));
594
+ for (let i = 0; i < skeletons.length && samples.length < DEEP_SAMPLES_PER_BATCH; i += step) {
595
+ const full = await readFullFile(resolvedRoot, skeletons[i].path);
596
+ if (full) {
597
+ samples.push({
598
+ path: full.path,
599
+ content: full.content.slice(0, DEEP_SAMPLE_CHARS)
600
+ });
601
+ }
602
+ }
603
+ }
604
+ const batchPathSet = new Set(batchPaths);
605
+ const allTestPairs = (await buildTestMap(resolvedRoot)).paired;
606
+ const testPairs = allTestPairs.filter(
607
+ (p) => batchPathSet.has(p.test) || batchPathSet.has(p.source)
608
+ );
609
+ const nextOffset = safeOffset + batchSize >= totalFiles ? null : safeOffset + batchSize;
610
+ return {
611
+ offset: safeOffset,
612
+ batchSize,
613
+ nextOffset,
614
+ totalFiles,
615
+ skeletons,
616
+ samples,
617
+ testPairs
618
+ };
619
+ }
620
+ var exec4, SOURCE_GLOB, SOURCE_IGNORE, DEFAULT_BATCH_SIZE, SKELETON_CHARS, DEEP_SAMPLE_CHARS, DEEP_SAMPLES_PER_BATCH;
621
+ var init_snapshot = __esm({
622
+ "src/snapshot/snapshot.ts"() {
623
+ "use strict";
624
+ init_sampler();
625
+ init_test_map();
626
+ exec4 = promisify4(execFile4);
627
+ SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}";
628
+ SOURCE_IGNORE = [
629
+ "**/node_modules/**",
630
+ "**/dist/**",
631
+ "**/build/**",
632
+ "**/.gradle/**",
633
+ "**/target/**",
634
+ "**/.git/**",
635
+ "**/vendor/**",
636
+ "**/__pycache__/**",
637
+ "**/venv/**",
638
+ "**/.venv/**",
639
+ "**/*.min.*",
640
+ "**/*.map",
641
+ "**/generated/**",
642
+ "**/R.java",
643
+ "**/BuildConfig.java"
644
+ ];
645
+ DEFAULT_BATCH_SIZE = 50;
646
+ SKELETON_CHARS = 500;
647
+ DEEP_SAMPLE_CHARS = 1500;
648
+ DEEP_SAMPLES_PER_BATCH = 3;
649
+ }
650
+ });
651
+
652
+ // src/drift/drift.ts
653
+ import fs4 from "fs/promises";
654
+ import path4 from "path";
655
+ import { execFile as execFile5 } from "child_process";
656
+ import { promisify as promisify5 } from "util";
657
+ async function getChangesWithStatus(resolvedRoot, fromHash) {
658
+ if (!fromHash || fromHash === "unknown") return null;
659
+ try {
660
+ const { stdout } = await exec5(
661
+ "git",
662
+ ["diff", "--name-status", "-M", fromHash, "HEAD"],
663
+ { cwd: resolvedRoot, maxBuffer: 10 * 1024 * 1024 }
664
+ );
665
+ const changes = [];
666
+ for (const line of stdout.split("\n")) {
667
+ if (!line.trim()) continue;
668
+ const parts = line.split(" ");
669
+ if (parts.some((p) => p.startsWith(".mason/"))) continue;
670
+ const code = parts[0];
671
+ if (code.startsWith("R") && parts.length >= 3) {
672
+ changes.push({
673
+ status: "renamed",
674
+ path: parts[2],
675
+ previousPath: parts[1]
676
+ });
677
+ } else if (code.startsWith("C") && parts.length >= 3) {
678
+ changes.push({ status: "added", path: parts[2] });
679
+ } else if (code === "A" && parts.length >= 2) {
680
+ changes.push({ status: "added", path: parts[1] });
681
+ } else if (code === "D" && parts.length >= 2) {
682
+ changes.push({ status: "deleted", path: parts[1] });
683
+ } else if (parts.length >= 2) {
684
+ changes.push({ status: "modified", path: parts[1] });
685
+ }
686
+ }
687
+ return changes;
688
+ } catch {
689
+ return null;
690
+ }
691
+ }
692
+ async function countCommitsBehind(resolvedRoot, fromHash) {
693
+ try {
694
+ const { stdout } = await exec5(
695
+ "git",
696
+ ["rev-list", "--count", `${fromHash}..HEAD`],
697
+ { cwd: resolvedRoot }
698
+ );
699
+ const count = Number.parseInt(stdout.trim(), 10);
700
+ return Number.isNaN(count) ? null : count;
701
+ } catch {
702
+ return null;
703
+ }
704
+ }
705
+ function collectMappedFiles(snapshot) {
706
+ const mappedFiles = /* @__PURE__ */ new Set();
707
+ for (const feature of Object.values(snapshot.features)) {
708
+ for (const f of feature.files) mappedFiles.add(f);
709
+ for (const t of feature.tests ?? []) mappedFiles.add(t);
710
+ }
711
+ for (const flow of Object.values(snapshot.flows)) {
712
+ for (const f of flow.chain) mappedFiles.add(f);
713
+ }
714
+ return mappedFiles;
715
+ }
716
+ async function findGhostFiles(resolvedRoot, mappedFiles) {
717
+ const ghosts = [];
718
+ for (const file of mappedFiles) {
719
+ try {
720
+ await fs4.access(path4.join(resolvedRoot, file));
721
+ } catch {
722
+ ghosts.push(file);
723
+ }
724
+ }
725
+ return ghosts.sort();
726
+ }
727
+ async function computeDrift(rootDir) {
728
+ const resolvedRoot = path4.resolve(rootDir);
729
+ const snapshot = await loadSnapshot(resolvedRoot);
730
+ if (!snapshot) return null;
731
+ const headHash = await getCurrentGitHash(resolvedRoot);
732
+ const totalFeatures = Object.keys(snapshot.features).length;
733
+ const totalFlows = Object.keys(snapshot.flows).length;
734
+ const hashFor = (entry) => entry.refreshedHash ?? snapshot.gitHash;
735
+ const distinctHashes = /* @__PURE__ */ new Set([snapshot.gitHash]);
736
+ for (const feature of Object.values(snapshot.features)) {
737
+ distinctHashes.add(hashFor(feature));
738
+ }
739
+ for (const flow of Object.values(snapshot.flows)) {
740
+ distinctHashes.add(hashFor(flow));
741
+ }
742
+ distinctHashes.delete("unknown");
743
+ const staleHashes = headHash === "unknown" ? [] : [...distinctHashes].filter((h) => h !== headHash);
744
+ const stale = staleHashes.length > 0;
745
+ const report = {
746
+ stale,
747
+ snapshotHash: snapshot.gitHash,
748
+ headHash,
749
+ commitsBehind: stale ? null : 0,
750
+ historyAvailable: true,
751
+ changedFiles: [],
752
+ staleFeatures: {},
753
+ staleFlows: {},
754
+ totalFeatures,
755
+ totalFlows,
756
+ unmappedFiles: [],
757
+ ghostFiles: [],
758
+ renames: [],
759
+ recommendation: "up-to-date"
760
+ };
761
+ if (!stale) return report;
762
+ const mappedFiles = collectMappedFiles(snapshot);
763
+ report.ghostFiles = await findGhostFiles(resolvedRoot, mappedFiles);
764
+ const changesByHash = /* @__PURE__ */ new Map();
765
+ const touchedByHash = /* @__PURE__ */ new Map();
766
+ for (const hash of staleHashes) {
767
+ const changes = await getChangesWithStatus(resolvedRoot, hash);
768
+ if (changes === null) {
769
+ report.historyAvailable = false;
770
+ report.recommendation = "full-rebuild";
771
+ return report;
772
+ }
773
+ changesByHash.set(hash, changes);
774
+ const touched = /* @__PURE__ */ new Set();
775
+ for (const change of changes) {
776
+ touched.add(change.path);
777
+ if (change.previousPath) touched.add(change.previousPath);
778
+ }
779
+ touchedByHash.set(hash, touched);
780
+ }
781
+ const commitCounts = await Promise.all(
782
+ staleHashes.map((hash) => countCommitsBehind(resolvedRoot, hash))
783
+ );
784
+ const validCounts = commitCounts.filter((c) => c !== null);
785
+ report.commitsBehind = validCounts.length > 0 ? Math.max(...validCounts) : null;
786
+ const emptySet = /* @__PURE__ */ new Set();
787
+ const touchedFor = (entry) => touchedByHash.get(hashFor(entry)) ?? emptySet;
788
+ for (const [name, feature] of Object.entries(snapshot.features)) {
789
+ const touched = touchedFor(feature);
790
+ const hits = [...feature.files, ...feature.tests ?? []].filter(
791
+ (f) => touched.has(f)
792
+ );
793
+ if (hits.length > 0) report.staleFeatures[name] = [...new Set(hits)];
794
+ }
795
+ for (const [name, flow] of Object.entries(snapshot.flows)) {
796
+ const touched = touchedFor(flow);
797
+ const hits = flow.chain.filter((f) => touched.has(f));
798
+ if (hits.length > 0) report.staleFlows[name] = [...new Set(hits)];
799
+ }
800
+ const allChanges = [...changesByHash.values()].flat();
801
+ report.changedFiles = [...new Set(allChanges.map((c) => c.path))].sort();
802
+ const sourceFileSet = new Set(await listSourceFiles(resolvedRoot));
803
+ const newPaths = allChanges.filter((c) => c.status === "added" || c.status === "renamed").map((c) => c.path);
804
+ report.unmappedFiles = [...new Set(newPaths)].filter((p) => sourceFileSet.has(p) && !mappedFiles.has(p)).sort();
805
+ const renameKeys = /* @__PURE__ */ new Set();
806
+ for (const change of allChanges) {
807
+ if (change.status !== "renamed" || !change.previousPath) continue;
808
+ const key = `${change.previousPath}\0${change.path}`;
809
+ if (renameKeys.has(key)) continue;
810
+ renameKeys.add(key);
811
+ report.renames.push({ from: change.previousPath, to: change.path });
812
+ }
813
+ const changedMapped = /* @__PURE__ */ new Set([
814
+ ...Object.values(report.staleFeatures).flat(),
815
+ ...Object.values(report.staleFlows).flat()
816
+ ]);
817
+ const changedFraction = mappedFiles.size > 0 ? changedMapped.size / mappedFiles.size : 0;
818
+ report.recommendation = changedMapped.size >= FULL_REBUILD_MIN_CHANGED_MAPPED_FILES && changedFraction > FULL_REBUILD_FRACTION ? "full-rebuild" : "incremental";
819
+ return report;
820
+ }
821
+ var exec5, FULL_REBUILD_FRACTION, FULL_REBUILD_MIN_CHANGED_MAPPED_FILES;
822
+ var init_drift = __esm({
823
+ "src/drift/drift.ts"() {
824
+ "use strict";
825
+ init_snapshot();
826
+ exec5 = promisify5(execFile5);
827
+ FULL_REBUILD_FRACTION = 0.4;
828
+ FULL_REBUILD_MIN_CHANGED_MAPPED_FILES = 10;
829
+ }
830
+ });
831
+
832
+ // src/context/lexical.ts
833
+ import path7 from "path";
834
+ function tokenize(text) {
835
+ return text.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length > 2 && !STOPWORDS.has(t));
836
+ }
837
+ function stem(token) {
838
+ return token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token;
839
+ }
840
+ function tokenSet(text) {
841
+ return new Set(tokenize(text).map(stem));
842
+ }
843
+ function scoreEntry(taskTokens, entry) {
844
+ const nameTokens = tokenSet(entry.name);
845
+ const descTokens = tokenSet(entry.description);
846
+ const fileTokens = tokenSet(entry.files.map((f) => path7.basename(f)).join(" "));
847
+ let score = 0;
848
+ for (const token of taskTokens) {
849
+ if (nameTokens.has(token)) score += 3;
850
+ else if (descTokens.has(token)) score += 1;
851
+ else if (fileTokens.has(token)) score += 1;
852
+ }
853
+ return score;
854
+ }
855
+ function jaccard(a, b) {
856
+ if (a.size === 0 && b.size === 0) return 0;
857
+ let intersection = 0;
858
+ for (const token of a) if (b.has(token)) intersection++;
859
+ return intersection / (a.size + b.size - intersection);
860
+ }
861
+ var STOPWORDS;
862
+ var init_lexical = __esm({
863
+ "src/context/lexical.ts"() {
864
+ "use strict";
865
+ STOPWORDS = /* @__PURE__ */ new Set([
866
+ "the",
867
+ "a",
868
+ "an",
869
+ "and",
870
+ "or",
871
+ "of",
872
+ "to",
873
+ "in",
874
+ "on",
875
+ "for",
876
+ "with",
877
+ "how",
878
+ "does",
879
+ "do",
880
+ "is",
881
+ "are",
882
+ "was",
883
+ "what",
884
+ "where",
885
+ "which",
886
+ "why",
887
+ "when",
888
+ "who",
889
+ "i",
890
+ "we",
891
+ "my",
892
+ "our",
893
+ "you",
894
+ "your",
895
+ "it",
896
+ "its",
897
+ "this",
898
+ "that",
899
+ "these",
900
+ "those",
901
+ "can",
902
+ "could",
903
+ "should",
904
+ "would",
905
+ "will",
906
+ "want",
907
+ "need",
908
+ "please",
909
+ "about",
910
+ "into",
911
+ "from",
912
+ "when",
913
+ "there",
914
+ "any",
915
+ "all",
916
+ "some",
917
+ "not",
918
+ "but",
919
+ "also",
920
+ "just",
921
+ "like",
922
+ "get",
923
+ "make",
924
+ "use",
925
+ "new",
926
+ "work",
927
+ "works",
928
+ "working",
929
+ "implement",
930
+ "implemented",
931
+ "change",
932
+ "changed",
933
+ "file",
934
+ "files",
935
+ "code"
936
+ ]);
937
+ }
938
+ });
939
+
940
+ // src/decisions/decisions.ts
941
+ var decisions_exports = {};
942
+ __export(decisions_exports, {
943
+ BODY_MAX_CHARS: () => BODY_MAX_CHARS,
944
+ MAX_ACTIVE_DECISIONS: () => MAX_ACTIVE_DECISIONS,
945
+ TITLE_MAX_CHARS: () => TITLE_MAX_CHARS,
946
+ decisionIdFor: () => decisionIdFor,
947
+ findNearDuplicate: () => findNearDuplicate,
948
+ loadDecisions: () => loadDecisions,
949
+ saveDecisionRecord: () => saveDecisionRecord,
950
+ upsertDecision: () => upsertDecision
951
+ });
952
+ import fs7 from "fs/promises";
953
+ import path8 from "path";
954
+ import { createHash } from "crypto";
955
+ function decisionsDir(rootDir) {
956
+ return path8.join(rootDir, ".mason", "decisions");
957
+ }
958
+ async function loadDecisions(rootDir) {
959
+ let entries;
960
+ try {
961
+ entries = await fs7.readdir(decisionsDir(rootDir));
962
+ } catch {
963
+ return [];
964
+ }
965
+ const records = [];
966
+ for (const entry of entries) {
967
+ if (!entry.endsWith(".json")) continue;
968
+ try {
969
+ const raw = await fs7.readFile(
970
+ path8.join(decisionsDir(rootDir), entry),
971
+ "utf-8"
972
+ );
973
+ const parsed = JSON.parse(raw);
974
+ if (parsed.version !== 1 || !parsed.id || !parsed.title || !parsed.body) {
975
+ continue;
976
+ }
977
+ records.push(parsed);
978
+ } catch {
979
+ continue;
980
+ }
981
+ }
982
+ return records.sort((a, b) => a.id.localeCompare(b.id));
983
+ }
984
+ async function saveDecisionRecord(rootDir, record) {
985
+ await fs7.mkdir(decisionsDir(rootDir), { recursive: true });
986
+ await fs7.writeFile(
987
+ path8.join(decisionsDir(rootDir), `${record.id}.json`),
988
+ JSON.stringify(record, null, 2) + "\n",
989
+ "utf-8"
990
+ );
991
+ }
992
+ function decisionIdFor(title, body, existingIds) {
993
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60).replace(/-+$/, "");
994
+ if (!existingIds.has(slug)) return slug || "decision";
995
+ const suffix = createHash("sha1").update(title + body).digest("hex").slice(0, 6);
996
+ return `${slug}-${suffix}`;
997
+ }
998
+ function findNearDuplicate(candidate, existing) {
999
+ const candidateTokens = tokenSet(`${candidate.title} ${candidate.body}`);
1000
+ const candidateFiles = new Set(candidate.files);
1001
+ let best = null;
1002
+ for (const record of existing) {
1003
+ if (record.status !== "active") continue;
1004
+ const similarity = jaccard(
1005
+ candidateTokens,
1006
+ tokenSet(`${record.title} ${record.body}`)
1007
+ );
1008
+ const sharesFile = record.files.some((f) => candidateFiles.has(f));
1009
+ const threshold = sharesFile ? DUPLICATE_JACCARD_WITH_SHARED_FILE : DUPLICATE_JACCARD;
1010
+ if (similarity >= threshold && (!best || similarity > best.similarity)) {
1011
+ best = { record, similarity };
1012
+ }
1013
+ }
1014
+ return best;
1015
+ }
1016
+ function sanitizeAnchorFiles(rootDir, files) {
1017
+ const resolvedRoot = path8.resolve(rootDir);
1018
+ return files.filter((f) => {
1019
+ const resolved = path8.resolve(resolvedRoot, f);
1020
+ return resolved.startsWith(resolvedRoot) && !f.startsWith("/") && !f.includes("..");
1021
+ });
1022
+ }
1023
+ async function upsertDecision(rootDir, input) {
1024
+ const title = input.title.trim();
1025
+ const body = input.body.trim();
1026
+ if (title.length === 0 || body.length === 0) {
1027
+ return { status: "error", error: "title and body must be non-empty" };
1028
+ }
1029
+ if (title.length > TITLE_MAX_CHARS) {
1030
+ return {
1031
+ status: "error",
1032
+ error: `title exceeds ${TITLE_MAX_CHARS} chars \u2014 tighten it to a specific headline`
1033
+ };
1034
+ }
1035
+ if (body.length > BODY_MAX_CHARS) {
1036
+ return {
1037
+ status: "error",
1038
+ error: `body exceeds ${BODY_MAX_CHARS} chars \u2014 record the decision, not the transcript`
1039
+ };
1040
+ }
1041
+ const existing = await loadDecisions(rootDir);
1042
+ const byId = new Map(existing.map((r) => [r.id, r]));
1043
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1044
+ const head = await getCurrentGitHash(rootDir);
1045
+ const warnings = [];
1046
+ const files = sanitizeAnchorFiles(rootDir, input.files ?? []);
1047
+ if (input.files && files.length < input.files.length) {
1048
+ warnings.push("some anchor paths were outside the repo and were dropped");
1049
+ }
1050
+ for (const f of files) {
1051
+ try {
1052
+ await fs7.access(path8.join(rootDir, f));
1053
+ } catch {
1054
+ warnings.push(`anchor file does not exist on disk: ${f}`);
1055
+ }
1056
+ }
1057
+ if (input.id) {
1058
+ const record2 = byId.get(input.id);
1059
+ if (!record2) {
1060
+ return { status: "error", error: `no decision with id "${input.id}"` };
1061
+ }
1062
+ const unchanged = record2.title === title && record2.body === body && record2.category === input.category && JSON.stringify(record2.files) === JSON.stringify(files.length > 0 ? files : record2.files);
1063
+ const updated = {
1064
+ ...record2,
1065
+ title,
1066
+ body,
1067
+ category: input.category,
1068
+ files: input.files !== void 0 ? files : record2.files,
1069
+ updatedAt: now,
1070
+ refreshedHash: head
1071
+ };
1072
+ await saveDecisionRecord(rootDir, updated);
1073
+ return {
1074
+ status: unchanged ? "reverified" : "updated",
1075
+ id: record2.id,
1076
+ totalActive: existing.filter((r) => r.status === "active").length,
1077
+ warnings
1078
+ };
1079
+ }
1080
+ if (!input.force) {
1081
+ const duplicate = findNearDuplicate({ title, body, files }, existing);
1082
+ if (duplicate) {
1083
+ return {
1084
+ status: "duplicate_suspected",
1085
+ existing: duplicate.record,
1086
+ 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.`
1087
+ };
1088
+ }
1089
+ }
1090
+ if (input.supersedes) {
1091
+ const old = byId.get(input.supersedes);
1092
+ if (!old) {
1093
+ return {
1094
+ status: "error",
1095
+ error: `no decision with id "${input.supersedes}" to supersede`
1096
+ };
1097
+ }
1098
+ const id2 = decisionIdFor(title, body, new Set(byId.keys()));
1099
+ await saveDecisionRecord(rootDir, {
1100
+ ...old,
1101
+ status: "superseded",
1102
+ supersededBy: id2,
1103
+ updatedAt: now
1104
+ });
1105
+ const record2 = {
1106
+ version: 1,
1107
+ id: id2,
1108
+ title,
1109
+ body,
1110
+ category: input.category,
1111
+ files,
1112
+ createdAt: now,
1113
+ updatedAt: now,
1114
+ refreshedHash: head,
1115
+ status: "active"
1116
+ };
1117
+ await saveDecisionRecord(rootDir, record2);
1118
+ return {
1119
+ status: "superseded_and_created",
1120
+ id: id2,
1121
+ totalActive: existing.filter((r) => r.status === "active").length,
1122
+ warnings
1123
+ };
1124
+ }
1125
+ const id = decisionIdFor(title, body, new Set(byId.keys()));
1126
+ const record = {
1127
+ version: 1,
1128
+ id,
1129
+ title,
1130
+ body,
1131
+ category: input.category,
1132
+ files,
1133
+ createdAt: now,
1134
+ updatedAt: now,
1135
+ refreshedHash: head,
1136
+ status: "active"
1137
+ };
1138
+ await saveDecisionRecord(rootDir, record);
1139
+ const totalActive = existing.filter((r) => r.status === "active").length + 1;
1140
+ const result = {
1141
+ status: "created",
1142
+ id,
1143
+ totalActive,
1144
+ warnings
1145
+ };
1146
+ if (totalActive > MAX_ACTIVE_DECISIONS) {
1147
+ result.pruneCandidates = existing.filter((r) => r.status === "superseded").map((r) => r.id).slice(0, 10);
1148
+ warnings.push(
1149
+ `${totalActive} active decisions exceeds the soft cap of ${MAX_ACTIVE_DECISIONS} \u2014 consider a cleanup PR (superseded records first)`
1150
+ );
1151
+ }
1152
+ return result;
1153
+ }
1154
+ var TITLE_MAX_CHARS, BODY_MAX_CHARS, MAX_ACTIVE_DECISIONS, DUPLICATE_JACCARD, DUPLICATE_JACCARD_WITH_SHARED_FILE;
1155
+ var init_decisions = __esm({
1156
+ "src/decisions/decisions.ts"() {
1157
+ "use strict";
1158
+ init_snapshot();
1159
+ init_lexical();
1160
+ TITLE_MAX_CHARS = 80;
1161
+ BODY_MAX_CHARS = 1500;
1162
+ MAX_ACTIVE_DECISIONS = 150;
1163
+ DUPLICATE_JACCARD = 0.5;
1164
+ DUPLICATE_JACCARD_WITH_SHARED_FILE = 0.35;
1165
+ }
1166
+ });
1167
+
1168
+ // src/impact/impact.ts
1169
+ var impact_exports = {};
1170
+ __export(impact_exports, {
1171
+ analyzeImpact: () => analyzeImpact
1172
+ });
1173
+ import fs8 from "fs/promises";
1174
+ import path9 from "path";
1175
+ import { execFile as execFile6 } from "child_process";
1176
+ import { promisify as promisify6 } from "util";
1177
+ import fg5 from "fast-glob";
1178
+ async function analyzeImpact(rootDir, targetFiles) {
1179
+ const resolvedRoot = path9.resolve(rootDir);
1180
+ const resolvedTargets = await resolveTargetFiles(resolvedRoot, targetFiles);
1181
+ const [cochange, references, tests] = await Promise.all([
1182
+ getCochangeFiles(resolvedRoot, resolvedTargets),
1183
+ getReferences(resolvedRoot, resolvedTargets),
1184
+ getRelatedTests(resolvedRoot, resolvedTargets)
1185
+ ]);
1186
+ return {
1187
+ targetFiles: resolvedTargets,
1188
+ cochange,
1189
+ references,
1190
+ tests
1191
+ };
1192
+ }
1193
+ async function resolveTargetFiles(rootDir, targets) {
1194
+ const resolved = [];
1195
+ for (const target of targets) {
1196
+ if (target.includes("/")) {
1197
+ resolved.push(target);
1198
+ continue;
1199
+ }
1200
+ const matches = await fg5(`**/${target}`, {
1201
+ cwd: rootDir,
1202
+ ignore: IGNORE2
1203
+ });
1204
+ if (matches.length > 0) {
1205
+ resolved.push(matches[0]);
1206
+ } else {
1207
+ const noExt = target.replace(/\.[^.]+$/, "");
1208
+ const extMatches = await fg5(`**/${noExt}.*`, {
1209
+ cwd: rootDir,
1210
+ ignore: IGNORE2
1211
+ });
1212
+ if (extMatches.length > 0) {
1213
+ resolved.push(extMatches[0]);
1214
+ } else {
1215
+ resolved.push(target);
1216
+ }
1217
+ }
1218
+ }
1219
+ return resolved;
1220
+ }
1221
+ async function getCochangeFiles(rootDir, targetFiles) {
1222
+ const cochangeCounts = /* @__PURE__ */ new Map();
1223
+ let totalTargetCommits = 0;
1224
+ for (const targetFile of targetFiles) {
1225
+ try {
1226
+ const { stdout: commitLog } = await exec6(
1227
+ "git",
1228
+ ["log", "--format=%H", "-n", "500", "--", targetFile],
1229
+ { cwd: rootDir, maxBuffer: 5e6 }
1230
+ );
1231
+ const commits = commitLog.trim().split("\n").filter(Boolean);
1232
+ totalTargetCommits += commits.length;
1233
+ if (commits.length === 0) continue;
1234
+ for (const commit of commits) {
1235
+ try {
1236
+ const { stdout: filesInCommit } = await exec6(
1237
+ "git",
1238
+ ["diff-tree", "--no-commit-id", "--name-only", "-r", commit],
1239
+ { cwd: rootDir }
1240
+ );
1241
+ const files = filesInCommit.trim().split("\n").filter(Boolean);
1242
+ for (const file of files) {
1243
+ if (targetFiles.includes(file)) continue;
1244
+ cochangeCounts.set(file, (cochangeCounts.get(file) ?? 0) + 1);
1245
+ }
1246
+ } catch {
1247
+ }
1248
+ }
1249
+ } catch {
1250
+ }
1251
+ }
1252
+ if (totalTargetCommits === 0) return [];
1253
+ return [...cochangeCounts.entries()].map(([file, count]) => ({
1254
+ file,
1255
+ cochangeRate: Math.round(count / totalTargetCommits * 100) / 100,
1256
+ sharedCommits: count
1257
+ })).filter((e) => e.cochangeRate >= 0.3 || e.sharedCommits >= 3).sort((a, b) => b.cochangeRate - a.cochangeRate).slice(0, 20);
1258
+ }
1259
+ async function getReferences(rootDir, targetFiles) {
1260
+ const searchNames = /* @__PURE__ */ new Set();
1261
+ for (const target of targetFiles) {
1262
+ const basename = path9.basename(target).replace(/\.[^.]+$/, "");
1263
+ searchNames.add(basename);
1264
+ }
1265
+ const allSourceFiles = await fg5(`**/${SOURCE_EXTENSIONS2}`, {
1266
+ cwd: rootDir,
1267
+ ignore: IGNORE2
1268
+ });
1269
+ const targetSet = new Set(targetFiles);
1270
+ const filesToSearch = allSourceFiles.filter((f) => !targetSet.has(f));
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*\()/;
1273
+ const batchSize = 50;
1274
+ for (let i = 0; i < filesToSearch.length; i += batchSize) {
1275
+ const batch = filesToSearch.slice(i, i + batchSize);
1276
+ await Promise.all(
1277
+ batch.map(async (file) => {
1278
+ try {
1279
+ const content = await fs8.readFile(
1280
+ path9.join(rootDir, file),
1281
+ "utf-8"
1282
+ );
1283
+ const lines = content.split("\n");
1284
+ for (const name of searchNames) {
1285
+ const regex = new RegExp(`\\b${escapeRegex(name)}\\b`);
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;
1294
+ }
1295
+ }
1296
+ } catch {
1297
+ }
1298
+ })
1299
+ );
1300
+ }
1301
+ return [...results.entries()].map(([file, { matches, isImport }]) => ({
1302
+ file,
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
+ });
1309
+ }
1310
+ async function getRelatedTests(rootDir, targetFiles) {
1311
+ const testPatterns = [
1312
+ "**/*.test.*",
1313
+ "**/*.spec.*",
1314
+ "**/*Test.kt",
1315
+ "**/*Test.java",
1316
+ "**/*Tests.kt",
1317
+ "**/*Tests.java",
1318
+ "**/test_*.py",
1319
+ "**/*_test.py",
1320
+ "**/*_test.go",
1321
+ "**/*Tests.swift",
1322
+ "**/*Test.swift",
1323
+ "**/*_test.rs"
1324
+ ];
1325
+ const testFiles = await fg5(testPatterns, { cwd: rootDir, ignore: IGNORE2 });
1326
+ const results = [];
1327
+ for (const target of targetFiles) {
1328
+ const targetBaseName = path9.basename(target).replace(/\.[^.]+$/, "");
1329
+ for (const testFile of testFiles) {
1330
+ const testBaseName = path9.basename(testFile).replace(/\.[^.]+$/, "");
1331
+ const sourceName = testBaseName.replace(/Test$|Tests$|Spec$|\.test$|\.spec$/, "").replace(/^test_|_test$/, "");
1332
+ if (sourceName === targetBaseName) {
1333
+ results.push({
1334
+ file: testFile,
1335
+ confidence: "exact"
1336
+ });
1337
+ }
1338
+ }
1339
+ }
1340
+ return results;
1341
+ }
1342
+ function escapeRegex(str) {
1343
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1344
+ }
1345
+ var exec6, IGNORE2, SOURCE_EXTENSIONS2;
1346
+ var init_impact = __esm({
1347
+ "src/impact/impact.ts"() {
1348
+ "use strict";
1349
+ exec6 = promisify6(execFile6);
1350
+ IGNORE2 = [
1351
+ "**/node_modules/**",
1352
+ "**/dist/**",
1353
+ "**/build/**",
1354
+ "**/.gradle/**",
1355
+ "**/target/**",
1356
+ "**/.git/**",
1357
+ "**/vendor/**",
1358
+ "**/__pycache__/**",
1359
+ "**/venv/**",
1360
+ "**/.venv/**",
1361
+ "**/generated/**"
1362
+ ];
1363
+ SOURCE_EXTENSIONS2 = "*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,h,dart,gradle.kts,gradle}";
1364
+ }
1365
+ });
1366
+
1367
+ // src/decisions/drift.ts
1368
+ import path10 from "path";
1369
+ async function computeDecisionDrift(rootDir, decisions) {
1370
+ const resolvedRoot = path10.resolve(rootDir);
1371
+ const records = decisions ?? await loadDecisions(resolvedRoot);
1372
+ const report = {
1373
+ historyAvailable: true,
1374
+ totalDecisions: records.length,
1375
+ staleDecisions: {}
1376
+ };
1377
+ const head = await getCurrentGitHash(resolvedRoot);
1378
+ const changesByHash = /* @__PURE__ */ new Map();
1379
+ for (const record of records) {
1380
+ if (record.status !== "active" || record.files.length === 0) continue;
1381
+ if (record.refreshedHash === head) continue;
1382
+ let touched = changesByHash.get(record.refreshedHash);
1383
+ if (touched === void 0) {
1384
+ const changes = await getChangesWithStatus(
1385
+ resolvedRoot,
1386
+ record.refreshedHash
1387
+ );
1388
+ if (changes === null) {
1389
+ touched = null;
1390
+ } else {
1391
+ touched = /* @__PURE__ */ new Set();
1392
+ for (const change of changes) {
1393
+ touched.add(change.path);
1394
+ if (change.previousPath) touched.add(change.previousPath);
1395
+ }
1396
+ }
1397
+ changesByHash.set(record.refreshedHash, touched);
1398
+ }
1399
+ if (touched === null) {
1400
+ report.historyAvailable = false;
1401
+ continue;
1402
+ }
1403
+ const hits = record.files.filter((f) => touched.has(f));
1404
+ if (hits.length > 0) {
1405
+ report.staleDecisions[record.id] = hits;
1406
+ }
1407
+ }
1408
+ return report;
1409
+ }
1410
+ var init_drift2 = __esm({
1411
+ "src/decisions/drift.ts"() {
1412
+ "use strict";
1413
+ init_drift();
1414
+ init_snapshot();
1415
+ init_decisions();
1416
+ }
1417
+ });
1418
+
1419
+ // src/context/assemble.ts
1420
+ var assemble_exports = {};
1421
+ __export(assemble_exports, {
1422
+ assembleContext: () => assembleContext
1423
+ });
1424
+ import path11 from "path";
1425
+ async function assembleContext(rootDir, task, files) {
1426
+ const resolvedRoot = path11.resolve(rootDir);
1427
+ const snapshot = await loadSnapshot(resolvedRoot);
1428
+ if (!snapshot) return null;
1429
+ const drift = await computeDrift(resolvedRoot);
1430
+ const allDecisions = await loadDecisions(resolvedRoot);
1431
+ const decisionDrift = await computeDecisionDrift(resolvedRoot, allDecisions);
1432
+ const taskTokens = tokenSet(task);
1433
+ const anchorFiles = new Set(files ?? []);
1434
+ const anchorBoost = (entryFiles) => {
1435
+ let boost = 0;
1436
+ for (const f of entryFiles) if (anchorFiles.has(f)) boost += 5;
1437
+ return boost;
1438
+ };
1439
+ const featureScores = Object.entries(snapshot.features).map(([name, feat]) => ({
1440
+ name,
1441
+ feat,
1442
+ score: scoreEntry(taskTokens, { name, description: feat.description, files: feat.files }) + anchorBoost([...feat.files, ...feat.tests ?? []])
1443
+ })).filter((e) => e.score > 0).sort((a, b) => b.score - a.score).slice(0, MAX_FEATURES);
1444
+ const flowScores = Object.entries(snapshot.flows).map(([name, flow]) => ({
1445
+ name,
1446
+ flow,
1447
+ score: scoreEntry(taskTokens, { name, description: flow.description, files: flow.chain }) + anchorBoost(flow.chain)
1448
+ })).filter((e) => e.score > 0).sort((a, b) => b.score - a.score).slice(0, MAX_FLOWS);
1449
+ const matchedEntryFiles = /* @__PURE__ */ new Set([
1450
+ ...featureScores.flatMap((e) => e.feat.files),
1451
+ ...flowScores.flatMap((e) => e.flow.chain)
1452
+ ]);
1453
+ const decisions = matchDecisions(
1454
+ allDecisions,
1455
+ taskTokens,
1456
+ anchorBoost,
1457
+ matchedEntryFiles,
1458
+ decisionDrift.staleDecisions
1459
+ );
1460
+ if (featureScores.length === 0 && flowScores.length === 0) {
1461
+ return noMatchBundle(snapshot, task, decisions);
1462
+ }
1463
+ const features = {};
1464
+ const staleMatches = [];
1465
+ for (const { name, feat, score } of featureScores) {
1466
+ const stale2 = drift?.staleFeatures[name] !== void 0;
1467
+ if (stale2) staleMatches.push(name);
1468
+ features[name] = {
1469
+ description: feat.description,
1470
+ files: feat.files,
1471
+ ...feat.tests && feat.tests.length > 0 ? { tests: feat.tests } : {},
1472
+ type: normalizeFeatureType(feat.type),
1473
+ score,
1474
+ stale: stale2
1475
+ };
1476
+ }
1477
+ const flows = {};
1478
+ for (const { name, flow, score } of flowScores) {
1479
+ const stale2 = drift?.staleFlows[name] !== void 0;
1480
+ if (stale2) staleMatches.push(name);
1481
+ flows[name] = {
1482
+ description: flow.description,
1483
+ chain: flow.chain,
1484
+ score,
1485
+ stale: stale2
1486
+ };
1487
+ }
1488
+ const impactTargets = [
1489
+ ...anchorFiles,
1490
+ ...featureScores.flatMap((e) => e.feat.files)
1491
+ ].slice(0, MAX_IMPACT_TARGETS);
1492
+ let impact = null;
1493
+ let impactTests = [];
1494
+ if (impactTargets.length > 0) {
1495
+ const result = await analyzeImpact(resolvedRoot, impactTargets);
1496
+ impact = {
1497
+ targets: result.targetFiles,
1498
+ cochange: result.cochange,
1499
+ references: result.references.slice(0, 10)
1500
+ };
1501
+ impactTests = result.tests.map((t) => t.file);
1502
+ }
1503
+ const relatedTests = [
1504
+ .../* @__PURE__ */ new Set([
1505
+ ...featureScores.flatMap((e) => e.feat.tests ?? []),
1506
+ ...impactTests
1507
+ ])
1508
+ ];
1509
+ const stale = drift?.stale ?? false;
1510
+ const staleDecisionIds = Object.keys(decisions).filter(
1511
+ (id) => decisions[id].stale
1512
+ );
1513
+ return {
1514
+ exists: true,
1515
+ task,
1516
+ features,
1517
+ flows,
1518
+ decisions,
1519
+ relatedTests,
1520
+ impact,
1521
+ freshness: {
1522
+ stale,
1523
+ recommendation: drift?.recommendation ?? "up-to-date",
1524
+ staleMatches
1525
+ },
1526
+ hint: bundleHint(stale, staleMatches, staleDecisionIds)
1527
+ };
1528
+ }
1529
+ function matchDecisions(allDecisions, taskTokens, anchorBoost, matchedEntryFiles, staleDecisions) {
1530
+ const scored = allDecisions.filter((d) => d.status === "active").map((d) => {
1531
+ let score = scoreEntry(taskTokens, {
1532
+ name: d.title,
1533
+ description: d.body,
1534
+ files: d.files
1535
+ }) + anchorBoost(d.files);
1536
+ if (d.files.some((f) => matchedEntryFiles.has(f))) {
1537
+ score += DECISION_FEATURE_OVERLAP_BOOST;
1538
+ }
1539
+ return { d, score };
1540
+ }).filter((e) => e.score > 0).sort((a, b) => b.score - a.score).slice(0, MAX_DECISIONS);
1541
+ const result = {};
1542
+ for (const { d, score } of scored) {
1543
+ result[d.id] = {
1544
+ title: d.title,
1545
+ body: d.body,
1546
+ category: d.category,
1547
+ files: d.files,
1548
+ score,
1549
+ stale: staleDecisions[d.id] !== void 0
1550
+ };
1551
+ }
1552
+ return result;
1553
+ }
1554
+ function bundleHint(stale, staleMatches, staleDecisionIds = []) {
1555
+ const parts = [];
1556
+ if (staleMatches.length > 0) {
1557
+ parts.push(
1558
+ `Entries [${staleMatches.join(", ")}] changed since they were last verified \u2014 read their files rather than trusting the descriptions, and consider mason_check_drift for a refresh plan.`
1559
+ );
1560
+ } else if (stale) {
1561
+ parts.push(
1562
+ "The matched entries are current, but other parts of the map have drifted \u2014 mason_check_drift shows what needs refreshing."
1563
+ );
1564
+ } else {
1565
+ parts.push(
1566
+ "Map is current. Start from the listed files; cochange/references show what else an edit would touch."
1567
+ );
1568
+ }
1569
+ if (staleDecisionIds.length > 0) {
1570
+ parts.push(
1571
+ `Decisions [${staleDecisionIds.join(", ")}] have anchor files that changed since they were recorded \u2014 verify each still holds; if it does, re-save it with its id to re-pin, otherwise update or supersede it via save_decision.`
1572
+ );
1573
+ }
1574
+ return parts.join(" ");
1575
+ }
1576
+ function noMatchBundle(snapshot, task, decisions) {
1577
+ const availableFeatures = {};
1578
+ for (const [name, feat] of Object.entries(snapshot.features)) {
1579
+ availableFeatures[name] = feat.description;
1580
+ }
1581
+ const availableFlows = {};
1582
+ for (const [name, flow] of Object.entries(snapshot.flows)) {
1583
+ availableFlows[name] = flow.description;
1584
+ }
1585
+ return {
1586
+ exists: true,
1587
+ task,
1588
+ features: {},
1589
+ flows: {},
1590
+ decisions,
1591
+ availableFeatures,
1592
+ availableFlows,
1593
+ hint: "No map entry matched the task wording. The full catalog is listed \u2014 pick the relevant entries and call get_context again with their names in the task, or read their files directly via get_snapshot."
1594
+ };
1595
+ }
1596
+ var MAX_FEATURES, MAX_FLOWS, MAX_IMPACT_TARGETS, MAX_DECISIONS, DECISION_FEATURE_OVERLAP_BOOST;
1597
+ var init_assemble = __esm({
1598
+ "src/context/assemble.ts"() {
1599
+ "use strict";
1600
+ init_snapshot();
1601
+ init_drift();
1602
+ init_impact();
1603
+ init_lexical();
1604
+ init_decisions();
1605
+ init_drift2();
1606
+ MAX_FEATURES = 5;
1607
+ MAX_FLOWS = 3;
1608
+ MAX_IMPACT_TARGETS = 3;
1609
+ MAX_DECISIONS = 5;
1610
+ DECISION_FEATURE_OVERLAP_BOOST = 2;
1611
+ }
1612
+ });
1613
+
1614
+ // src/confluence/client.ts
1615
+ var client_exports = {};
1616
+ __export(client_exports, {
1617
+ createConfluenceClient: () => createConfluenceClient
1618
+ });
1619
+ function createConfluenceClient(config, fetchFn = fetch) {
1620
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
1621
+ const auth = "Basic " + Buffer.from(`${config.email}:${config.apiToken}`).toString("base64");
1622
+ async function call(method, path15, body) {
1623
+ const res = await fetchFn(`${baseUrl}${path15}`, {
1624
+ method,
1625
+ headers: {
1626
+ Authorization: auth,
1627
+ Accept: "application/json",
1628
+ "Content-Type": "application/json"
1629
+ },
1630
+ body: body ? JSON.stringify(body) : void 0
1631
+ });
1632
+ if (!res.ok) {
1633
+ const text = await res.text();
1634
+ throw new Error(
1635
+ `Confluence ${method} ${path15} failed: ${res.status} ${res.statusText} \u2014 ${text}`
1636
+ );
1637
+ }
1638
+ if (res.status === 204) return null;
1639
+ return res.json();
1640
+ }
1641
+ function toPage(raw) {
1642
+ return {
1643
+ id: raw.id,
1644
+ title: raw.title,
1645
+ version: raw.version?.number ?? 1,
1646
+ body: raw.body?.storage?.value ?? "",
1647
+ parentId: raw.parentId
1648
+ };
1649
+ }
1650
+ return {
1651
+ async resolveSpaceId(spaceKey) {
1652
+ const res = await call(
1653
+ "GET",
1654
+ `/wiki/api/v2/spaces?keys=${encodeURIComponent(spaceKey)}`
1655
+ );
1656
+ const space = res.results?.find((s) => s.key === spaceKey);
1657
+ if (!space) {
1658
+ throw new Error(`Confluence space not found: ${spaceKey}`);
1659
+ }
1660
+ return space.id;
1661
+ },
1662
+ async listSpaces() {
1663
+ const all = [];
1664
+ let cursor = "/wiki/api/v2/spaces?limit=100";
1665
+ while (cursor) {
1666
+ const res = await call("GET", cursor);
1667
+ for (const s of res.results ?? []) {
1668
+ all.push({ id: s.id, key: s.key, name: s.name ?? s.key });
1669
+ }
1670
+ const next = res._links?.next;
1671
+ if (!next) break;
1672
+ cursor = next.startsWith("/") ? next : `/${next}`;
1673
+ }
1674
+ return all;
1675
+ },
1676
+ async listRootPages(spaceId) {
1677
+ const url = `/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}/pages?depth=root&limit=50`;
1678
+ const res = await call("GET", url);
1679
+ return (res.results ?? []).map((p) => ({ id: p.id, title: p.title }));
1680
+ },
1681
+ async findPageByTitle(spaceId, title) {
1682
+ const url = `/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}/pages?title=${encodeURIComponent(title)}&body-format=storage&limit=1`;
1683
+ const res = await call("GET", url);
1684
+ const match = res.results?.find((p) => p.title === title);
1685
+ return match ? toPage(match) : null;
1686
+ },
1687
+ async createPage(input) {
1688
+ const res = await call("POST", "/wiki/api/v2/pages", {
1689
+ spaceId: input.spaceId,
1690
+ status: "current",
1691
+ title: input.title,
1692
+ parentId: input.parentId,
1693
+ body: {
1694
+ representation: "storage",
1695
+ value: input.body
1696
+ }
1697
+ });
1698
+ return toPage(res);
1699
+ },
1700
+ async updatePage(input) {
1701
+ const res = await call("PUT", `/wiki/api/v2/pages/${input.id}`, {
1702
+ id: input.id,
1703
+ status: "current",
1704
+ title: input.title,
1705
+ parentId: input.parentId,
1706
+ body: {
1707
+ representation: "storage",
1708
+ value: input.body
1709
+ },
1710
+ version: {
1711
+ number: input.version + 1
1712
+ }
1713
+ });
1714
+ return toPage(res);
1715
+ }
1716
+ };
1717
+ }
1718
+ var init_client = __esm({
1719
+ "src/confluence/client.ts"() {
1720
+ "use strict";
1721
+ }
1722
+ });
1723
+
1724
+ // src/llm/config.ts
1725
+ var config_exports = {};
1726
+ __export(config_exports, {
1727
+ detectCLI: () => detectCLI,
1728
+ getDefaultModel: () => getDefaultModel,
1729
+ loadConfig: () => loadConfig,
1730
+ loadConfluenceConfig: () => loadConfluenceConfig,
1731
+ needsApiKey: () => needsApiKey,
1732
+ saveConfig: () => saveConfig,
1733
+ saveConfluenceConfig: () => saveConfluenceConfig,
1734
+ validateProvider: () => validateProvider
1735
+ });
1736
+ import fs9 from "fs/promises";
1737
+ import path12 from "path";
1738
+ import os from "os";
1739
+ import { execFile as execFile7 } from "child_process";
1740
+ import { promisify as promisify7 } from "util";
1741
+ function configDir() {
1742
+ return path12.join(os.homedir(), ".mason");
1743
+ }
1744
+ function configFile() {
1745
+ return path12.join(configDir(), "config.json");
1746
+ }
1747
+ async function loadConfig() {
1748
+ try {
1749
+ const raw = await fs9.readFile(configFile(), "utf-8");
1750
+ return JSON.parse(raw);
1751
+ } catch {
1752
+ return null;
1753
+ }
1754
+ }
1755
+ async function saveConfig(config) {
1756
+ await fs9.mkdir(configDir(), { recursive: true });
1757
+ await fs9.writeFile(configFile(), JSON.stringify(config, null, 2), "utf-8");
1758
+ }
1759
+ function getDefaultModel(provider) {
1760
+ return DEFAULT_MODELS[provider];
1761
+ }
1762
+ function validateProvider(value) {
1763
+ const valid = ["claude", "gemini", "openai", "ollama"];
1764
+ if (!valid.includes(value)) {
1765
+ throw new Error(
1766
+ `Invalid provider "${value}". Must be one of: ${valid.join(", ")}`
1767
+ );
1768
+ }
1769
+ return value;
1770
+ }
1771
+ async function detectCLI(provider) {
1772
+ const cliName = provider === "claude" ? "claude" : provider === "gemini" ? "gemini" : provider === "ollama" ? "ollama" : null;
1773
+ if (!cliName) return { available: false };
1774
+ try {
1775
+ const { stdout } = await exec7(cliName, ["--version"]);
1776
+ return { available: true, version: stdout.trim() };
1777
+ } catch {
1778
+ return { available: false };
1779
+ }
1780
+ }
1781
+ function needsApiKey(provider) {
1782
+ return provider === "openai";
1783
+ }
1784
+ async function saveConfluenceConfig(confluence) {
1785
+ const existing = await loadConfig() ?? { provider: "claude" };
1786
+ await saveConfig({ ...existing, confluence });
1787
+ }
1788
+ async function loadConfluenceConfig() {
1789
+ const config = await loadConfig();
1790
+ return config?.confluence ?? null;
1791
+ }
1792
+ var exec7, DEFAULT_MODELS;
1793
+ var init_config = __esm({
1794
+ "src/llm/config.ts"() {
1795
+ "use strict";
1796
+ exec7 = promisify7(execFile7);
1797
+ DEFAULT_MODELS = {
1798
+ claude: "claude-sonnet-4-20250514",
1799
+ gemini: "gemini-2.5-flash",
1800
+ openai: "gpt-4o",
1801
+ ollama: "llama3"
1802
+ };
1803
+ }
1804
+ });
1805
+
1806
+ // src/confluence/url.ts
1807
+ var url_exports = {};
1808
+ __export(url_exports, {
1809
+ normalizeAtlassianBaseUrl: () => normalizeAtlassianBaseUrl
1810
+ });
1811
+ function normalizeAtlassianBaseUrl(input) {
1812
+ const trimmed = input.trim().replace(/\/+$/, "");
1813
+ if (!trimmed) throw new Error("Confluence baseUrl is required.");
1814
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
1815
+ if (trimmed.includes(".")) return `https://${trimmed}`;
1816
+ return `https://${trimmed}.atlassian.net`;
1817
+ }
1818
+ var init_url = __esm({
1819
+ "src/confluence/url.ts"() {
1820
+ "use strict";
1821
+ }
1822
+ });
1823
+
1824
+ // src/confluence/renderer.ts
1825
+ function escape(value) {
1826
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1827
+ }
1828
+ function infoPanel(text) {
1829
+ return `<ac:structured-macro ac:name="info"><ac:rich-text-body><p>${escape(text)}</p></ac:rich-text-body></ac:structured-macro>`;
1830
+ }
1831
+ function featurePageTitle(prefix, name) {
1832
+ return `${prefix}${name}`;
1833
+ }
1834
+ function renderFeaturePage(options) {
1835
+ const overviewBody = `<h2>What it does</h2><p>${escape(options.productDescription)}</p>`;
1836
+ const flowsBody = options.flowDescriptions.length ? `<h2>How it fits in</h2><ul>` + options.flowDescriptions.map(
1837
+ (f) => `<li><strong>${escape(f.name)}</strong> \u2014 ${escape(f.description)}</li>`
1838
+ ).join("") + `</ul>` : "";
1839
+ const navBody = `<p><ac:link><ri:page ri:content-title="${escape(options.indexPageTitle)}"/><ac:plain-text-link-body><![CDATA[Back to ${options.indexPageTitle}]]></ac:plain-text-link-body></ac:link></p>`;
1840
+ const footer = infoPanel(
1841
+ `Generated from code by Mason. This page is overwritten on each sync \u2014 edit the code, not the page.`
1842
+ );
1843
+ const body = overviewBody + flowsBody + navBody + footer;
1844
+ return {
1845
+ title: options.name,
1846
+ body
1847
+ };
1848
+ }
1849
+ function renderIndexPage(options) {
1850
+ if (options.featureTitles.length === 0) {
1851
+ return infoPanel("No features in the snapshot yet.");
1852
+ }
1853
+ const list = `<h2>Features</h2><ul>` + options.featureTitles.map((name) => {
1854
+ const pageTitle = featurePageTitle(options.featurePrefix, name);
1855
+ return `<li><ac:link><ri:page ri:content-title="${escape(pageTitle)}"/><ac:plain-text-link-body><![CDATA[${name}]]></ac:plain-text-link-body></ac:link></li>`;
1856
+ }).join("") + `</ul>`;
1857
+ const banner = infoPanel(
1858
+ `Generated from code by Mason. Maintained automatically \u2014 edit the code, not this page.`
1859
+ );
1860
+ return banner + list;
1861
+ }
1862
+ function renderChangelogSection(section) {
1863
+ const segments = [];
1864
+ if (section.addedFeatures.length) {
1865
+ segments.push(
1866
+ `<p><strong>Added features:</strong> ${section.addedFeatures.map(escape).join(", ")}</p>`
1867
+ );
1868
+ }
1869
+ if (section.removedFeatures.length) {
1870
+ segments.push(
1871
+ `<p><strong>Removed features:</strong> ${section.removedFeatures.map(escape).join(", ")}</p>`
1872
+ );
1873
+ }
1874
+ if (section.changedFeatures.length) {
1875
+ segments.push(
1876
+ `<p><strong>Updated features:</strong> ${section.changedFeatures.map(escape).join(", ")}</p>`
1877
+ );
1878
+ }
1879
+ if (section.addedFlows.length) {
1880
+ segments.push(
1881
+ `<p><strong>Added flows:</strong> ${section.addedFlows.map(escape).join(", ")}</p>`
1882
+ );
1883
+ }
1884
+ if (section.removedFlows.length) {
1885
+ segments.push(
1886
+ `<p><strong>Removed flows:</strong> ${section.removedFlows.map(escape).join(", ")}</p>`
1887
+ );
1888
+ }
1889
+ if (segments.length === 0) {
1890
+ segments.push(`<p><em>No meaningful changes detected.</em></p>`);
1891
+ }
1892
+ return `<h3>${escape(section.syncedAt)}</h3>` + segments.join("");
1893
+ }
1894
+ function renderChangelogPage(sections) {
1895
+ if (sections.length === 0) {
1896
+ return `<p><em>No sync has run yet.</em></p>`;
1897
+ }
1898
+ return sections.join("\n<hr/>\n");
1899
+ }
1900
+ function flowsForFeature(featureFiles, flows) {
1901
+ const fileSet = new Set(featureFiles);
1902
+ const result = [];
1903
+ for (const [name, flow] of Object.entries(flows)) {
1904
+ if (flow.chain.some((file) => fileSet.has(file))) {
1905
+ result.push({ name, description: flow.description });
1906
+ }
1907
+ }
1908
+ return result;
1909
+ }
1910
+ var init_renderer = __esm({
1911
+ "src/confluence/renderer.ts"() {
1912
+ "use strict";
1913
+ }
1914
+ });
1915
+
1916
+ // src/confluence/diff.ts
1917
+ import fs10 from "fs/promises";
1918
+ import path13 from "path";
1919
+ import { createHash as createHash2 } from "crypto";
1920
+ function hashDescription(description) {
1921
+ return createHash2("sha256").update(description, "utf8").digest("hex");
1922
+ }
1923
+ function syncStateDir(rootDir) {
1924
+ return path13.join(rootDir, ".mason");
1925
+ }
1926
+ function syncStatePath(rootDir) {
1927
+ return path13.join(syncStateDir(rootDir), "confluence-sync.json");
1928
+ }
1929
+ async function loadSyncState(rootDir) {
1930
+ try {
1931
+ const raw = await fs10.readFile(syncStatePath(rootDir), "utf-8");
1932
+ const parsed = JSON.parse(raw);
1933
+ if (parsed.version !== 2) return null;
1934
+ return parsed;
1935
+ } catch {
1936
+ return null;
1937
+ }
1938
+ }
1939
+ async function saveSyncState(rootDir, state) {
1940
+ await fs10.mkdir(syncStateDir(rootDir), { recursive: true });
1941
+ await fs10.writeFile(
1942
+ syncStatePath(rootDir),
1943
+ JSON.stringify(state, null, 2),
1944
+ "utf-8"
1945
+ );
1946
+ }
1947
+ function computeDiff(previous, current, syncedAt) {
1948
+ const prevFeatures = previous?.lastSnapshot.features ?? {};
1949
+ const prevFlows = previous?.lastSnapshot.flows ?? {};
1950
+ const currentFeatureNames = Object.keys(current.features);
1951
+ const prevFeatureNames = Object.keys(prevFeatures);
1952
+ const addedFeatures = currentFeatureNames.filter(
1953
+ (n) => !(n in prevFeatures)
1954
+ );
1955
+ const removedFeatures = prevFeatureNames.filter(
1956
+ (n) => !(n in current.features)
1957
+ );
1958
+ const changedFeatures = currentFeatureNames.filter(
1959
+ (n) => n in prevFeatures && prevFeatures[n].description !== current.features[n].description
1960
+ );
1961
+ const currentFlowNames = Object.keys(current.flows);
1962
+ const prevFlowNames = Object.keys(prevFlows);
1963
+ const addedFlows = currentFlowNames.filter((n) => !(n in prevFlows));
1964
+ const removedFlows = prevFlowNames.filter((n) => !(n in current.flows));
1965
+ return {
1966
+ syncedAt,
1967
+ addedFeatures,
1968
+ removedFeatures,
1969
+ changedFeatures,
1970
+ addedFlows,
1971
+ removedFlows
1972
+ };
1973
+ }
1974
+ function isMeaningfulDiff(diff) {
1975
+ return diff.addedFeatures.length > 0 || diff.removedFeatures.length > 0 || diff.changedFeatures.length > 0 || diff.addedFlows.length > 0 || diff.removedFlows.length > 0;
1976
+ }
1977
+ function snapshotMinimal(snapshot) {
1978
+ const features = {};
1979
+ for (const [k, v] of Object.entries(snapshot.features)) {
1980
+ features[k] = { description: v.description };
1981
+ }
1982
+ const flows = {};
1983
+ for (const [k, v] of Object.entries(snapshot.flows)) {
1984
+ flows[k] = { description: v.description };
1985
+ }
1986
+ return { features, flows };
1987
+ }
1988
+ var init_diff = __esm({
1989
+ "src/confluence/diff.ts"() {
1990
+ "use strict";
1991
+ }
1992
+ });
1993
+
1994
+ // src/llm/providers.ts
1995
+ import { execFile as execFile8, spawn } from "child_process";
1996
+ import { promisify as promisify8 } from "util";
1997
+ async function callLLM(config, userMessage, systemPrompt) {
1998
+ const model = config.model ?? getDefaultModel(config.provider);
1999
+ const system = systemPrompt ?? CLAUDE_MD_SYSTEM_PROMPT;
2000
+ switch (config.provider) {
2001
+ case "claude":
2002
+ if (config.apiKey) {
2003
+ return {
2004
+ type: "response",
2005
+ text: await callClaudeAPI(config.apiKey, model, system, userMessage)
2006
+ };
2007
+ }
2008
+ return {
2009
+ type: "response",
2010
+ text: await callClaudeCLI(system, userMessage)
2011
+ };
2012
+ case "ollama":
2013
+ return {
2014
+ type: "response",
2015
+ text: await callOllamaCLI(
2016
+ config.ollamaHost ?? "http://localhost:11434",
2017
+ model,
2018
+ system,
2019
+ userMessage
2020
+ )
2021
+ };
2022
+ case "gemini":
2023
+ if (config.apiKey) {
2024
+ return {
2025
+ type: "response",
2026
+ text: await callGeminiAPI(config.apiKey, model, system, userMessage)
2027
+ };
2028
+ }
2029
+ return {
2030
+ type: "response",
2031
+ text: await callGeminiCLI(system, userMessage)
2032
+ };
2033
+ case "openai":
2034
+ if (config.apiKey) {
2035
+ return {
2036
+ type: "response",
2037
+ text: await callOpenAIAPI(config.apiKey, model, system, userMessage)
2038
+ };
2039
+ }
2040
+ return {
2041
+ type: "prompt",
2042
+ text: formatPromptForCopy(system, userMessage)
2043
+ };
2044
+ }
2045
+ }
2046
+ function formatPromptForCopy(system, userMessage) {
2047
+ return `${system}
2048
+
2049
+ ---
2050
+
2051
+ ${userMessage}`;
2052
+ }
2053
+ function spawnWithStdin(command, args, input) {
2054
+ return new Promise((resolve2, reject) => {
2055
+ const proc = spawn(command, args, {
2056
+ stdio: ["pipe", "pipe", "pipe"],
2057
+ timeout: 3e5
2058
+ });
2059
+ const onSigint = () => proc.kill("SIGINT");
2060
+ process.on("SIGINT", onSigint);
2061
+ let stdout = "";
2062
+ let stderr = "";
2063
+ proc.stdout.on("data", (data) => {
2064
+ stdout += data.toString();
2065
+ });
2066
+ proc.stderr.on("data", (data) => {
2067
+ stderr += data.toString();
2068
+ });
2069
+ proc.on("close", (code) => {
2070
+ process.off("SIGINT", onSigint);
2071
+ if (code === 0) {
2072
+ resolve2(stdout.trim());
2073
+ } else {
2074
+ reject(new Error(`${command} exited with code ${code}: ${stderr}`));
2075
+ }
2076
+ });
2077
+ proc.on("error", (err) => {
2078
+ process.off("SIGINT", onSigint);
2079
+ reject(err);
2080
+ });
2081
+ proc.stdin.write(input);
2082
+ proc.stdin.end();
2083
+ });
2084
+ }
2085
+ async function callClaudeCLI(system, userMessage) {
2086
+ return spawnWithStdin("claude", ["-p", "--system-prompt", system], userMessage);
2087
+ }
2088
+ async function callGeminiCLI(system, userMessage) {
2089
+ const prompt = `<system>
2090
+ ${system}
2091
+ </system>
2092
+
2093
+ ${userMessage}`;
2094
+ return spawnWithStdin("gemini", ["-p", ""], prompt);
2095
+ }
2096
+ async function callOllamaCLI(host, model, system, userMessage) {
2097
+ const response = await fetch(`${host}/api/chat`, {
2098
+ method: "POST",
2099
+ headers: { "Content-Type": "application/json" },
2100
+ body: JSON.stringify({
2101
+ model,
2102
+ stream: false,
2103
+ messages: [
2104
+ { role: "system", content: system },
2105
+ { role: "user", content: userMessage }
2106
+ ]
2107
+ })
2108
+ });
2109
+ const result = await response.json();
2110
+ return result.message?.content ?? "";
2111
+ }
2112
+ async function callClaudeAPI(apiKey, model, system, userMessage) {
2113
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
2114
+ const client = new Anthropic({ apiKey });
2115
+ const response = await client.messages.create({
2116
+ model,
2117
+ max_tokens: 8192,
2118
+ system,
2119
+ messages: [{ role: "user", content: userMessage }]
2120
+ });
2121
+ const textBlock = response.content.find((b) => b.type === "text");
2122
+ return textBlock?.text ?? "";
2123
+ }
2124
+ async function callGeminiAPI(apiKey, model, system, userMessage) {
2125
+ const { default: OpenAI } = await import("openai");
2126
+ const client = new OpenAI({
2127
+ apiKey,
2128
+ baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/"
2129
+ });
2130
+ const response = await client.chat.completions.create({
2131
+ model,
2132
+ max_tokens: 8192,
2133
+ messages: [
2134
+ { role: "system", content: system },
2135
+ { role: "user", content: userMessage }
2136
+ ]
2137
+ });
2138
+ return response.choices[0]?.message?.content ?? "";
2139
+ }
2140
+ async function callOpenAIAPI(apiKey, model, system, userMessage) {
2141
+ const { default: OpenAI } = await import("openai");
2142
+ const client = new OpenAI({ apiKey });
2143
+ const response = await client.chat.completions.create({
2144
+ model,
2145
+ max_tokens: 8192,
2146
+ messages: [
2147
+ { role: "system", content: system },
2148
+ { role: "user", content: userMessage }
2149
+ ]
2150
+ });
2151
+ return response.choices[0]?.message?.content ?? "";
2152
+ }
2153
+ var exec8, CLAUDE_MD_SYSTEM_PROMPT;
2154
+ var init_providers = __esm({
2155
+ "src/llm/providers.ts"() {
2156
+ "use strict";
2157
+ init_config();
2158
+ exec8 = promisify8(execFile8);
2159
+ CLAUDE_MD_SYSTEM_PROMPT = `You are Mason, a context engineering tool. You've been given a comprehensive analysis of a codebase including:
2160
+ - Git history stats (commit patterns, frequently changed files, stale directories)
2161
+ - Project structure (directory layout, file counts by type)
2162
+ - Curated code samples (key architectural files with previews)
2163
+ - Test-to-source file mapping
2164
+
2165
+ Your job: write a COMPLETE CLAUDE.md file from scratch based ONLY on the analysis data provided below. Do NOT read any existing files in the project. Do NOT reference or preserve any existing CLAUDE.md. Generate the entire document fresh.
2166
+
2167
+ CRITICAL: Output ONLY the raw markdown content. No preamble, no summary, no "Here's the CLAUDE.md:", no explanation, no questions, no commentary. Start directly with "# CLAUDE.md" and end with the last line of content. Your entire response will be written directly to a file.
2168
+
2169
+ The CLAUDE.md should include:
2170
+ - Project overview (what it is, tech stack, architecture)
2171
+ - Module/package structure and boundaries
2172
+ - Code conventions and patterns you observe in the samples
2173
+ - Testing conventions and coverage
2174
+ - Build and development commands
2175
+ - Important files and hot spots
2176
+ - Any warnings or gotchas
2177
+
2178
+ Be specific and actionable. Reference actual file paths. Don't be generic \u2014 every rule should be grounded in what you see in the data.`;
2179
+ }
2180
+ });
2181
+
2182
+ // src/confluence/rewrite.ts
2183
+ function buildPrompt(input) {
2184
+ return `Rewrite the descriptions below for a product audience. Return ONLY a JSON object of the form {"features": {"name": "rewritten description", ...}, "flows": {...}}.
2185
+
2186
+ ${JSON.stringify(input, null, 2)}`;
2187
+ }
2188
+ function parseRewriteResponse(raw) {
2189
+ let cleaned = raw.trim();
2190
+ if (cleaned.startsWith("```")) {
2191
+ cleaned = cleaned.replace(/^```(?:json)?\n?/, "").replace(/\n?```$/, "");
2192
+ }
2193
+ try {
2194
+ const parsed = JSON.parse(cleaned);
2195
+ return {
2196
+ features: parsed.features ?? {},
2197
+ flows: parsed.flows ?? {}
2198
+ };
2199
+ } catch {
2200
+ const match = raw.match(/\{[\s\S]*\}/);
2201
+ if (match) {
2202
+ try {
2203
+ const parsed = JSON.parse(match[0]);
2204
+ return {
2205
+ features: parsed.features ?? {},
2206
+ flows: parsed.flows ?? {}
2207
+ };
2208
+ } catch {
2209
+ return { features: {}, flows: {} };
2210
+ }
2211
+ }
2212
+ return { features: {}, flows: {} };
2213
+ }
2214
+ }
2215
+ function pick(source, keys) {
2216
+ const out = {};
2217
+ for (const k of keys) out[k] = source[k];
2218
+ return out;
2219
+ }
2220
+ async function rewriteForProduct(snapshot, config, ctx = {}) {
2221
+ const featureHashes = hashEntries(snapshot.features);
2222
+ const flowHashes = hashEntries(snapshot.flows);
2223
+ const missFeatures = missingNames(
2224
+ snapshot.features,
2225
+ featureHashes,
2226
+ ctx.previousCache?.features
2227
+ );
2228
+ const missFlows = missingNames(
2229
+ snapshot.flows,
2230
+ flowHashes,
2231
+ ctx.previousCache?.flows
2232
+ );
2233
+ let parsed = { features: {}, flows: {} };
2234
+ if (missFeatures.length > 0 || missFlows.length > 0) {
2235
+ const input = {
2236
+ features: pick(snapshot.features, missFeatures),
2237
+ flows: pick(snapshot.flows, missFlows)
2238
+ };
2239
+ const prompt = buildPrompt(input);
2240
+ const llm = ctx.llm ?? callLLM;
2241
+ const result = await llm(config, prompt, PM_REWRITE_SYSTEM_PROMPT);
2242
+ const text = typeof result === "string" ? result : result.type === "response" ? result.text : "";
2243
+ if (text) parsed = parseRewriteResponse(text);
2244
+ }
2245
+ const features = resolve(
2246
+ snapshot.features,
2247
+ featureHashes,
2248
+ parsed.features,
2249
+ ctx.previousCache?.features
2250
+ );
2251
+ const flows = resolve(
2252
+ snapshot.flows,
2253
+ flowHashes,
2254
+ parsed.flows,
2255
+ ctx.previousCache?.flows
2256
+ );
2257
+ return {
2258
+ features: features.descriptions,
2259
+ flows: flows.descriptions,
2260
+ cache: { features: features.cache, flows: flows.cache }
2261
+ };
2262
+ }
2263
+ function hashEntries(entries) {
2264
+ const out = {};
2265
+ for (const [name, entry] of Object.entries(entries)) {
2266
+ out[name] = hashDescription(entry.description);
2267
+ }
2268
+ return out;
2269
+ }
2270
+ function isHit(prev, hash) {
2271
+ return !!prev && prev.sourceHash === hash && !prev.fallback;
2272
+ }
2273
+ function missingNames(entries, hashes, prevCache) {
2274
+ return Object.keys(entries).filter(
2275
+ (name) => !isHit(prevCache?.[name], hashes[name])
2276
+ );
2277
+ }
2278
+ function resolve(entries, hashes, rewritten, prevCache) {
2279
+ const descriptions = {};
2280
+ const cache = {};
2281
+ for (const [name, entry] of Object.entries(entries)) {
2282
+ const hash = hashes[name];
2283
+ const prev = prevCache?.[name];
2284
+ if (isHit(prev, hash)) {
2285
+ descriptions[name] = prev.product;
2286
+ cache[name] = { sourceHash: hash, product: prev.product };
2287
+ continue;
2288
+ }
2289
+ const fresh = rewritten[name];
2290
+ if (typeof fresh === "string" && fresh.trim().length > 0) {
2291
+ descriptions[name] = fresh;
2292
+ cache[name] = { sourceHash: hash, product: fresh };
2293
+ } else {
2294
+ descriptions[name] = entry.description;
2295
+ cache[name] = { sourceHash: hash, product: entry.description, fallback: true };
2296
+ }
2297
+ }
2298
+ return { descriptions, cache };
2299
+ }
2300
+ var PM_REWRITE_SYSTEM_PROMPT;
2301
+ var init_rewrite = __esm({
2302
+ "src/confluence/rewrite.ts"() {
2303
+ "use strict";
2304
+ init_providers();
2305
+ init_diff();
2306
+ PM_REWRITE_SYSTEM_PROMPT = `You are Mason, rewriting an engineering-flavoured concept map into product-readable language for a company wiki.
2307
+
2308
+ You will receive a JSON object with two maps:
2309
+ - "features": each entry has a description and a list of source file paths.
2310
+ - "flows": each entry has a description and an ordered chain of file paths.
2311
+
2312
+ Your job: rewrite EACH description so a Product Manager, designer, or non-engineering stakeholder can understand what the system does \u2014 without seeing any code. Treat the file paths as hints, not content. Do NOT include them in your output.
2313
+
2314
+ Hard rules:
2315
+ - NEVER mention file names, directory names, file extensions, class names, function names, repository names, framework names, or libraries.
2316
+ - NEVER use words like "module", "service", "handler", "controller", "ViewModel", "repository", "endpoint", "API", "schema", "interface", "class".
2317
+ - Use plain English. Focus on what users or the business can do, what data moves, what decisions get made, and why it matters.
2318
+ - 1\u20133 sentences per description. Concrete. No filler.
2319
+ - Preserve the original keys exactly; only the description values change.
2320
+
2321
+ Output ONLY raw JSON with the same shape as the input \u2014 same keys, rewritten descriptions. No markdown, no code fences, no preamble.`;
2322
+ }
2323
+ });
2324
+
2325
+ // src/confluence/sync.ts
2326
+ var sync_exports = {};
2327
+ __export(sync_exports, {
2328
+ exportToConfluence: () => exportToConfluence
2329
+ });
2330
+ async function exportToConfluence(rootDir, config, options = {}, deps) {
2331
+ const confluence = config.confluence;
2332
+ if (!confluence) {
2333
+ throw new Error(
2334
+ "No Confluence credentials configured. Ask your assistant to call mason_set_confluence first."
2335
+ );
2336
+ }
2337
+ const snapshot = await loadSnapshot(rootDir);
2338
+ if (!snapshot) {
2339
+ throw new Error(
2340
+ "No snapshot found. Build the concept map first (ask your assistant to run mason_init and follow the playbook)."
2341
+ );
2342
+ }
2343
+ const client = deps?.client ?? createConfluenceClient(confluence);
2344
+ const rewrite = deps?.rewrite ?? rewriteForProduct;
2345
+ const publishedFeatures = {};
2346
+ for (const [name, entry] of Object.entries(snapshot.features)) {
2347
+ if (entry.type !== "infrastructure") publishedFeatures[name] = entry;
2348
+ }
2349
+ const publishSnapshot = { ...snapshot, features: publishedFeatures };
2350
+ const indexTitle = options.indexPageTitle ?? DEFAULT_INDEX_TITLE;
2351
+ const changelogTitle = options.changelogPageTitle ?? DEFAULT_CHANGELOG_TITLE;
2352
+ const featurePrefix = options.featurePagePrefix ?? DEFAULT_FEATURE_PREFIX;
2353
+ const spaceId = await client.resolveSpaceId(confluence.spaceKey);
2354
+ const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
2355
+ const previousState = await loadSyncState(rootDir);
2356
+ const previousHashes = previousState?.pageHashes ?? {};
2357
+ const nextHashes = {};
2358
+ const productLanguage = await rewrite(publishSnapshot, config, {
2359
+ previousCache: previousState?.rewriteCache
2360
+ });
2361
+ const indexBody = renderIndexPage({
2362
+ featureTitles: Object.keys(publishSnapshot.features),
2363
+ featurePrefix
2364
+ });
2365
+ const indexPage = await upsertPage({
2366
+ client,
2367
+ spaceId,
2368
+ title: indexTitle,
2369
+ parentId: confluence.parentPageId,
2370
+ renderedBody: indexBody,
2371
+ previousHash: previousHashes[indexTitle]
2372
+ });
2373
+ nextHashes[indexTitle] = indexPage.hash;
2374
+ const created = [];
2375
+ const updated = [];
2376
+ const unchanged = [];
2377
+ const featurePageIds = {};
2378
+ for (const [name, entry] of Object.entries(publishSnapshot.features)) {
2379
+ const title = featurePageTitle(featurePrefix, name);
2380
+ const productDescription = productLanguage.features[name] ?? entry.description;
2381
+ const relatedFlows = flowsForFeature(entry.files, publishSnapshot.flows).map(
2382
+ (f) => ({
2383
+ name: f.name,
2384
+ description: productLanguage.flows[f.name] ?? f.description
2385
+ })
2386
+ );
2387
+ const rendered = renderFeaturePage({
2388
+ name,
2389
+ productDescription,
2390
+ flowDescriptions: relatedFlows,
2391
+ indexPageTitle: indexTitle
2392
+ });
2393
+ const result = await upsertPage({
2394
+ client,
2395
+ spaceId,
2396
+ title,
2397
+ parentId: indexPage.id,
2398
+ renderedBody: rendered.body,
2399
+ previousHash: previousHashes[title]
2400
+ });
2401
+ nextHashes[title] = result.hash;
2402
+ featurePageIds[name] = result.id;
2403
+ if (result.outcome === "created") created.push(title);
2404
+ else if (result.outcome === "updated") updated.push(title);
2405
+ else unchanged.push(title);
2406
+ }
2407
+ const diff = computeDiff(previousState, publishSnapshot, syncedAt);
2408
+ const hadChanges = previousState === null || isMeaningfulDiff(diff);
2409
+ const previousSections = previousState?.changelogSections ?? [];
2410
+ let newSections = previousSections;
2411
+ if (hadChanges) {
2412
+ const section = renderChangelogSection(diff);
2413
+ newSections = [section, ...previousSections].slice(0, 50);
2414
+ }
2415
+ const changelogBody = renderChangelogPage(newSections);
2416
+ const changelogPage = await upsertPage({
2417
+ client,
2418
+ spaceId,
2419
+ title: changelogTitle,
2420
+ parentId: indexPage.id,
2421
+ renderedBody: changelogBody,
2422
+ previousHash: previousHashes[changelogTitle]
2423
+ });
2424
+ nextHashes[changelogTitle] = changelogPage.hash;
2425
+ const nextState = {
2426
+ version: 2,
2427
+ syncedAt,
2428
+ pageIds: {
2429
+ index: indexPage.id,
2430
+ changelog: changelogPage.id,
2431
+ features: featurePageIds
2432
+ },
2433
+ lastSnapshot: snapshotMinimal(publishSnapshot),
2434
+ changelogSections: newSections,
2435
+ rewriteCache: productLanguage.cache,
2436
+ pageHashes: nextHashes
2437
+ };
2438
+ await saveSyncState(rootDir, nextState);
2439
+ return {
2440
+ created,
2441
+ updated,
2442
+ unchanged,
2443
+ indexPageId: indexPage.id,
2444
+ changelogPageId: changelogPage.id,
2445
+ hadChanges
2446
+ };
2447
+ }
2448
+ async function upsertPage(args) {
2449
+ const hash = hashDescription(args.renderedBody);
2450
+ const existing = await args.client.findPageByTitle(args.spaceId, args.title);
2451
+ if (!existing) {
2452
+ const page = await args.client.createPage({
2453
+ spaceId: args.spaceId,
2454
+ title: args.title,
2455
+ parentId: args.parentId,
2456
+ body: args.renderedBody
2457
+ });
2458
+ return { id: page.id, outcome: "created", hash };
2459
+ }
2460
+ if (args.previousHash === hash) {
2461
+ return { id: existing.id, outcome: "unchanged", hash };
2462
+ }
2463
+ const updated = await args.client.updatePage({
2464
+ id: existing.id,
2465
+ title: args.title,
2466
+ parentId: args.parentId,
2467
+ body: args.renderedBody,
2468
+ version: existing.version
2469
+ });
2470
+ return { id: updated.id, outcome: "updated", hash };
2471
+ }
2472
+ var DEFAULT_INDEX_TITLE, DEFAULT_CHANGELOG_TITLE, DEFAULT_FEATURE_PREFIX;
2473
+ var init_sync = __esm({
2474
+ "src/confluence/sync.ts"() {
2475
+ "use strict";
2476
+ init_snapshot();
2477
+ init_client();
2478
+ init_renderer();
2479
+ init_diff();
2480
+ init_rewrite();
2481
+ DEFAULT_INDEX_TITLE = "Mason \u2014 System Map";
2482
+ DEFAULT_CHANGELOG_TITLE = "Mason \u2014 Changelog";
2483
+ DEFAULT_FEATURE_PREFIX = "Feature: ";
2484
+ }
2485
+ });
2486
+
2487
+ // src/mcp/server.ts
2488
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2489
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2490
+ import { z } from "zod";
2491
+
2492
+ // src/mcp/tools.ts
2493
+ import fs11 from "fs/promises";
2494
+ import path14 from "path";
2495
+ import { execFile as execFile9 } from "child_process";
2496
+ import { promisify as promisify9 } from "util";
2497
+ import fg6 from "fast-glob";
2498
+
2499
+ // src/analyzers/git-history.ts
2500
+ import { execFile } from "child_process";
2501
+ import { promisify } from "util";
2502
+
2503
+ // src/analyzers/base.ts
2504
+ import fs from "fs/promises";
2505
+ import fg from "fast-glob";
2506
+ var BaseAnalyzer = class {
2507
+ async findFiles(patterns, root) {
2508
+ return fg(patterns, {
2509
+ cwd: root,
2510
+ ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**"],
2511
+ absolute: true
2512
+ });
2513
+ }
2514
+ async readFile(filePath) {
2515
+ return fs.readFile(filePath, "utf-8");
2516
+ }
2517
+ createFinding(partial) {
2518
+ return {
2519
+ analyzer: this.name,
2520
+ category: partial.category,
2521
+ confidence: partial.confidence,
2522
+ summary: partial.summary,
2523
+ evidence: partial.evidence ?? [],
2524
+ ruleCandidate: partial.ruleCandidate ?? null
2525
+ };
2526
+ }
2527
+ createResult(findings, gaps, startTime) {
2528
+ return {
2529
+ analyzer: this.name,
2530
+ findings,
2531
+ gaps,
2532
+ durationMs: Date.now() - startTime
2533
+ };
2534
+ }
2535
+ };
2536
+
2537
+ // src/analyzers/git-history.ts
2538
+ var exec = promisify(execFile);
2539
+ var GitHistoryAnalyzer = class extends BaseAnalyzer {
2540
+ name = "git-history";
2541
+ async analyze(context) {
2542
+ const startTime = Date.now();
2543
+ const findings = [];
2544
+ const gaps = [];
2545
+ if (!context.gitAvailable) {
2546
+ return this.createResult([], [], startTime);
2547
+ }
2548
+ const [staleFindings, staleGaps] = await this.findStaleDirectories(context);
2549
+ findings.push(...staleFindings);
2550
+ gaps.push(...staleGaps);
2551
+ const hotFindings = await this.findHotFiles(context);
2552
+ findings.push(...hotFindings);
2553
+ const commitFindings = await this.analyzeCommitPatterns(context);
2554
+ findings.push(...commitFindings);
2555
+ return this.createResult(findings, gaps, startTime);
2556
+ }
2557
+ async git(args, cwd) {
2558
+ try {
2559
+ const { stdout } = await exec("git", args, { cwd, maxBuffer: 1e7 });
2560
+ return stdout.trim();
2561
+ } catch {
2562
+ return "";
2563
+ }
2564
+ }
2565
+ async findStaleDirectories(context) {
2566
+ const findings = [];
2567
+ const gaps = [];
2568
+ const output = await this.git(
2569
+ ["log", "--all", "--format=%ci", "--name-only", "--diff-filter=AMCR", "-n", "500"],
2570
+ context.rootDir
2571
+ );
2572
+ if (!output) return [findings, gaps];
2573
+ const dirLastTouch = /* @__PURE__ */ new Map();
2574
+ let currentDate = null;
2575
+ for (const line of output.split("\n")) {
2576
+ if (!line) continue;
2577
+ if (/^\d{4}-\d{2}-\d{2}/.test(line)) {
2578
+ currentDate = new Date(line);
2579
+ } else if (currentDate) {
2580
+ const topDir = line.split("/")[0];
2581
+ if (topDir && !topDir.startsWith(".") && !topDir.includes("node_modules")) {
2582
+ const existing = dirLastTouch.get(topDir);
2583
+ if (!existing || currentDate > existing) {
2584
+ dirLastTouch.set(topDir, currentDate);
2585
+ }
2586
+ }
2587
+ }
2588
+ }
2589
+ const sixMonthsAgo = /* @__PURE__ */ new Date();
2590
+ sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
2591
+ for (const [dir, lastTouch] of dirLastTouch) {
2592
+ if (lastTouch < sixMonthsAgo) {
2593
+ const monthsStale = Math.floor(
2594
+ (Date.now() - lastTouch.getTime()) / (1e3 * 60 * 60 * 24 * 30)
2595
+ );
2596
+ findings.push(
2597
+ this.createFinding({
2598
+ category: "risk",
2599
+ confidence: 0.7,
2600
+ summary: `Directory "${dir}" hasn't been modified in ${monthsStale} months`,
2601
+ evidence: [
2602
+ { filePath: dir, detail: `Last commit: ${lastTouch.toISOString().split("T")[0]}` }
2603
+ ],
2604
+ ruleCandidate: `Do not refactor or modify files in "${dir}/" unless explicitly asked \u2014 this area has been stable for ${monthsStale} months and may be legacy code.`
2605
+ })
2606
+ );
2607
+ gaps.push({
2608
+ analyzer: this.name,
2609
+ question: `Directory "${dir}" hasn't been touched in ${monthsStale} months. Is it deprecated, stable, or legacy?`,
2610
+ context: `Last modified: ${lastTouch.toISOString().split("T")[0]}`,
2611
+ answerKey: `stale-dir-${dir}`
2612
+ });
2613
+ }
2614
+ }
2615
+ return [findings, gaps];
2616
+ }
2617
+ async findHotFiles(context) {
2618
+ const findings = [];
2619
+ const output = await this.git(
2620
+ ["log", "--since=3 months ago", "--format=", "--name-only"],
2621
+ context.rootDir
2622
+ );
2623
+ if (!output) return findings;
2624
+ const fileCounts = /* @__PURE__ */ new Map();
2625
+ for (const line of output.split("\n")) {
2626
+ if (!line || line.startsWith(".") || line.includes("node_modules")) continue;
2627
+ fileCounts.set(line, (fileCounts.get(line) ?? 0) + 1);
2628
+ }
2629
+ const sorted = [...fileCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10);
2630
+ if (sorted.length > 0 && sorted[0][1] >= 5) {
2631
+ const hotFiles = sorted.filter(([, count]) => count >= 5);
2632
+ if (hotFiles.length > 0) {
2633
+ findings.push(
2634
+ this.createFinding({
2635
+ category: "risk",
2636
+ confidence: 0.8,
2637
+ summary: `${hotFiles.length} files changed frequently in the last 3 months`,
2638
+ evidence: hotFiles.map(([file, count]) => ({
2639
+ filePath: file,
2640
+ detail: `${count} commits`
2641
+ })),
2642
+ ruleCandidate: `These files change frequently and are high-risk for conflicts: ${hotFiles.map(([f]) => f).join(", ")}. Take extra care when modifying them.`
2643
+ })
2644
+ );
2645
+ }
2646
+ }
2647
+ return findings;
2648
+ }
2649
+ async analyzeCommitPatterns(context) {
2650
+ const findings = [];
2651
+ const output = await this.git(
2652
+ ["log", "--format=%s", "-n", "100"],
2653
+ context.rootDir
2654
+ );
2655
+ if (!output) return findings;
2656
+ const messages = output.split("\n").filter(Boolean);
2657
+ const conventionalPattern = /^(feat|fix|chore|docs|style|refactor|test|perf|ci|build|revert)(\(.+\))?:/;
2658
+ const conventionalCount = messages.filter(
2659
+ (m) => conventionalPattern.test(m)
2660
+ ).length;
2661
+ const conventionalRatio = conventionalCount / messages.length;
2662
+ if (conventionalRatio > 0.5) {
2663
+ findings.push(
2664
+ this.createFinding({
2665
+ category: "convention",
2666
+ confidence: Math.min(conventionalRatio + 0.1, 1),
2667
+ summary: `${Math.round(conventionalRatio * 100)}% of recent commits use conventional commit format`,
2668
+ evidence: [
2669
+ {
2670
+ filePath: ".git",
2671
+ detail: `${conventionalCount} of ${messages.length} commits match`
2672
+ }
2673
+ ],
2674
+ ruleCandidate: "Use conventional commit format: type(scope): description (e.g., feat(auth): add login endpoint)"
2675
+ })
2676
+ );
2677
+ }
2678
+ const ticketPattern = /[A-Z]+-\d+|#\d+/;
2679
+ const ticketCount = messages.filter((m) => ticketPattern.test(m)).length;
2680
+ const ticketRatio = ticketCount / messages.length;
2681
+ if (ticketRatio > 0.3) {
2682
+ findings.push(
2683
+ this.createFinding({
2684
+ category: "convention",
2685
+ confidence: ticketRatio,
2686
+ summary: `${Math.round(ticketRatio * 100)}% of commits reference issue/ticket IDs`,
2687
+ evidence: [
2688
+ {
2689
+ filePath: ".git",
2690
+ detail: `${ticketCount} of ${messages.length} commits have ticket refs`
2691
+ }
2692
+ ],
2693
+ ruleCandidate: "Include issue/ticket references in commit messages when applicable."
2694
+ })
2695
+ );
2696
+ }
2697
+ return findings;
2698
+ }
2699
+ };
2700
+
2701
+ // src/analyzers/index.ts
2702
+ var analyzers = [new GitHistoryAnalyzer()];
2703
+ async function runAll(context) {
2704
+ return Promise.all(analyzers.map((a) => a.analyze(context)));
2705
+ }
2706
+
2707
+ // src/utils/git.ts
2708
+ import { execFile as execFile2 } from "child_process";
2709
+ import { promisify as promisify2 } from "util";
2710
+ var exec2 = promisify2(execFile2);
2711
+ async function isGitRepo(dir) {
2712
+ try {
2713
+ await exec2("git", ["rev-parse", "--git-dir"], { cwd: dir });
2714
+ return true;
2715
+ } catch {
2716
+ return false;
2717
+ }
2718
+ }
2719
+
2720
+ // src/mcp/tools.ts
2721
+ init_sampler();
2722
+ init_snapshot();
2723
+ init_drift();
2724
+
2725
+ // src/snapshot/prompt.ts
2726
+ var BATCH_SYSTEM_PROMPT = `You are Mason, building one piece of a larger concept-to-files map via a Map-Reduce pattern.
2727
+
2728
+ You are seeing ONE batch of files from this project \u2014 not the whole codebase. Other batches will be processed separately and merged with yours in a final reduce step.
2729
+
2730
+ Your job for this batch: identify the features and flows that involve the files in this batch, and return a partial concept map.
2731
+
2732
+ Respond with ONLY a JSON object. No markdown, no explanation, no code fences. Just the raw JSON. Same shape as the full map (\`{"features": {...}, "flows": {...}}\`).
2733
+
2734
+ CRITICAL: name features in PRODUCT-NATURAL language (e.g., "home screen", "authentication", "checkout"). Do NOT add platform or layer suffixes \u2014 call both the Android and iOS home-screen files part of a feature named "home screen". This is what lets the reduce step merge platform variants from other batches into a single product feature.
2735
+
2736
+ Other rules:
2737
+ - Only include files that you see in this batch. Don't predict files in other batches.
2738
+ - Use the FULL relative file paths exactly as given.
2739
+ - Classify each feature with a "type": "capability" (user-facing functionality) or "infrastructure" (internal plumbing with no end user \u2014 DI/service wiring, configuration, logging, adapters, build tooling). When unsure, use "capability".
2740
+ - Each feature should have 1\u20138 files from this batch \u2014 partials can be narrow.
2741
+ - Flows in a partial only make sense if all their chain steps are in this batch. Skip flows that span batches; the reduce step will assemble them.
2742
+ - Include test files in "tests" when present in this batch.
2743
+ - Two views of the batch: FILE HEADERS (every file in the batch, skeleton-level) and REPRESENTATIVE BODIES (deeper read of a few for grounding). Use the bodies to learn the codebase's domain vocabulary; use the headers to know which files exist.`;
2744
+ var REDUCE_SYSTEM_PROMPT = `You are Mason, merging partial concept maps from a Map-Reduce pass into a single unified map.
2745
+
2746
+ You will receive an array of \`partials\`, each produced from one batch of files. Your job: merge them into one coherent concept-to-files map for the whole project.
2747
+
2748
+ Respond with ONLY a JSON object: \`{"features": {...}, "flows": {...}}\`. No markdown, no preamble.
2749
+
2750
+ Merge rules:
2751
+ - If two partials use the same feature name (e.g., both have "home screen"), MERGE them \u2014 combine their \`files\` and \`tests\` arrays (dedupe), and reconcile descriptions by picking the more product-natural wording or merging the two.
2752
+ - If two partials use *near-duplicate* feature names that clearly refer to the same product concept ("home screen" vs "home view", "auth" vs "authentication"), merge them under the more product-natural name.
2753
+ - If a partial split what should be one feature by platform ("home Android" + "home iOS"), merge into a single platform-agnostic feature ("home screen").
2754
+ - Preserve each feature's "type" ("capability" or "infrastructure"). When merged partials disagree on a feature's type, prefer "capability". If a partial omitted the type, infer it: user-facing functionality is "capability"; internal plumbing with no end user (DI/service wiring, config, logging, adapters) is "infrastructure".
2755
+ - For flows that were skipped by partials because they span batches, reconstruct them when you can see the full chain across multiple partials.
2756
+ - Every file that appears in any partial MUST end up in some feature in the unified map. Don't silently drop files.
2757
+ - Feature descriptions in the final map should be 1\u20132 sentences, written for a product/PM audience \u2014 concrete and specific, but free of code-level detail.
2758
+ - Each feature should have 2\u20138 files. If merging produces a feature with 20+ files, consider whether it should be split into sub-features.`;
2759
+ function buildBatchPrompt(batch) {
2760
+ const skeletonBlocks = batch.skeletons.map(
2761
+ (f) => `--- ${f.path} ---
2762
+ ${f.content}${f.content.length >= 500 ? "\n... (truncated)" : ""}`
2763
+ ).join("\n\n");
2764
+ const sampleBlocks = batch.samples.map(
2765
+ (f) => `=== ${f.path} (deeper read) ===
2766
+ ${f.content}${f.content.length >= 1500 ? "\n... (truncated)" : ""}`
2767
+ ).join("\n\n");
2768
+ const batchInfo = `Batch ${Math.floor(batch.offset / batch.batchSize) + 1}: files ${batch.offset + 1}\u2013${batch.offset + batch.skeletons.length} of ${batch.totalFiles}.`;
2769
+ let prompt = `${batchInfo}
2770
+
2771
+ === FILE HEADERS (every file in this batch) ===
2772
+
2773
+ ${skeletonBlocks}
2774
+
2775
+ === REPRESENTATIVE BODIES (for grounding) ===
2776
+
2777
+ ${sampleBlocks}`;
2778
+ if (batch.testPairs && batch.testPairs.length > 0) {
2779
+ const testBlock = batch.testPairs.map((p) => `${p.test} \u2192 ${p.source}`).join("\n");
2780
+ prompt += `
2781
+
2782
+ === TEST \u2192 SOURCE MAPPINGS (for this batch) ===
2783
+
2784
+ ${testBlock}`;
2785
+ }
2786
+ return prompt;
2787
+ }
2788
+ function buildReducePrompt(partials) {
2789
+ return `Merge the following ${partials.length} partial concept maps into a single unified map.
2790
+
2791
+ ${JSON.stringify({ partials }, null, 2)}`;
2792
+ }
2793
+ var REFRESH_REDUCE_SYSTEM_PROMPT = `You are Mason, merging a scoped refresh into an existing concept-to-files map.
2794
+
2795
+ Only a subset of the project's files was re-analyzed (they changed since the map was built). You receive the existing full map, the list of re-analyzed file paths, and partial concept maps derived from ONLY those files.
2796
+
2797
+ Respond with ONLY a JSON object: \`{"features": {...}, "flows": {...}}\` \u2014 the COMPLETE updated map. No markdown, no preamble.
2798
+
2799
+ Merge rules:
2800
+ - Entries in the existing map that reference none of the re-analyzed files: copy them through UNCHANGED.
2801
+ - Entries that reference re-analyzed files: update them using the partials \u2014 adjust descriptions, add new files, drop files that moved elsewhere.
2802
+ - Merge partial features into existing features when they're the same product concept, even if named slightly differently ("auth" vs "authentication") \u2014 keep the existing name unless the new one is clearly more product-natural.
2803
+ - Features whose files were all deleted or renamed away: remove them by omitting them from your output.
2804
+ - Every file that appears in any partial MUST end up in some feature. Don't silently drop files.
2805
+ - Do not invent or alter entries for files you haven't seen.`;
2806
+ function buildRefreshReducePrompt(existingMap, refreshedFiles, partials) {
2807
+ return `Merge this scoped refresh into the existing concept map.
2808
+
2809
+ === EXISTING MAP ===
2810
+ ${JSON.stringify(existingMap, null, 2)}
2811
+
2812
+ === RE-ANALYZED FILES ===
2813
+ ${refreshedFiles.join("\n")}
2814
+
2815
+ === PARTIALS (derived from the re-analyzed files only) ===
2816
+ ${JSON.stringify({ partials }, null, 2)}`;
2817
+ }
2818
+
2819
+ // src/snapshot/partials.ts
2820
+ import fs5 from "fs/promises";
2821
+ import path5 from "path";
2822
+ function partialsDir(rootDir) {
2823
+ return path5.join(rootDir, ".mason", "partial-snapshots");
2824
+ }
2825
+ function partialPath(rootDir, batchId) {
2826
+ return path5.join(partialsDir(rootDir), `${batchId}.json`);
2827
+ }
2828
+ function isSafeBatchId(batchId) {
2829
+ return /^[a-zA-Z0-9_-]+$/.test(batchId);
2830
+ }
2831
+ async function savePartial(rootDir, partial) {
2832
+ if (!isSafeBatchId(partial.batchId)) {
2833
+ throw new Error(`Invalid batchId: ${partial.batchId}`);
2834
+ }
2835
+ await fs5.mkdir(partialsDir(rootDir), { recursive: true });
2836
+ await fs5.writeFile(
2837
+ partialPath(rootDir, partial.batchId),
2838
+ JSON.stringify(partial, null, 2),
2839
+ "utf-8"
2840
+ );
2841
+ }
2842
+ async function loadAllPartials(rootDir) {
2843
+ let entries;
2844
+ try {
2845
+ entries = await fs5.readdir(partialsDir(rootDir));
2846
+ } catch {
2847
+ return [];
2848
+ }
2849
+ const partials = [];
2850
+ for (const entry of entries) {
2851
+ if (!entry.endsWith(".json")) continue;
2852
+ try {
2853
+ const raw = await fs5.readFile(
2854
+ path5.join(partialsDir(rootDir), entry),
2855
+ "utf-8"
2856
+ );
2857
+ const parsed = JSON.parse(raw);
2858
+ if (parsed && parsed.batchId && parsed.features && parsed.flows) {
2859
+ partials.push(parsed);
2860
+ }
2861
+ } catch {
2862
+ }
2863
+ }
2864
+ partials.sort((a, b) => a.offset - b.offset);
2865
+ return partials;
2866
+ }
2867
+ function scopePath(rootDir) {
2868
+ return path5.join(partialsDir(rootDir), "scope.json");
2869
+ }
2870
+ async function saveScope(rootDir, files) {
2871
+ await fs5.mkdir(partialsDir(rootDir), { recursive: true });
2872
+ await fs5.writeFile(
2873
+ scopePath(rootDir),
2874
+ JSON.stringify({ files, savedAt: (/* @__PURE__ */ new Date()).toISOString() }, null, 2),
2875
+ "utf-8"
2876
+ );
2877
+ }
2878
+ async function loadScope(rootDir) {
2879
+ try {
2880
+ const raw = await fs5.readFile(scopePath(rootDir), "utf-8");
2881
+ const parsed = JSON.parse(raw);
2882
+ if (Array.isArray(parsed?.files)) return parsed.files;
2883
+ return null;
2884
+ } catch {
2885
+ return null;
2886
+ }
2887
+ }
2888
+ async function clearScope(rootDir) {
2889
+ await fs5.rm(scopePath(rootDir), { force: true });
2890
+ }
2891
+ async function clearAllPartials(rootDir) {
2892
+ try {
2893
+ await fs5.rm(partialsDir(rootDir), { recursive: true, force: true });
2894
+ } catch {
2895
+ }
2896
+ }
2897
+ function batchIdFor(offset) {
2898
+ return `batch-${String(offset).padStart(6, "0")}`;
2899
+ }
2900
+
2901
+ // src/mcp/init.ts
2902
+ import fs6 from "fs/promises";
2903
+ import path6 from "path";
2904
+ function masonDir(rootDir) {
2905
+ return path6.join(rootDir, ".mason");
2906
+ }
2907
+ function markerPath(rootDir) {
2908
+ return path6.join(masonDir(rootDir), "project.json");
2909
+ }
2910
+ async function loadProjectMarker(rootDir) {
2911
+ try {
2912
+ const raw = await fs6.readFile(markerPath(rootDir), "utf-8");
2913
+ const parsed = JSON.parse(raw);
2914
+ if (parsed.version !== 1) return null;
2915
+ return parsed;
2916
+ } catch {
2917
+ return null;
2918
+ }
2919
+ }
2920
+ async function saveProjectMarker(rootDir, marker) {
2921
+ await fs6.mkdir(masonDir(rootDir), { recursive: true });
2922
+ await fs6.writeFile(
2923
+ markerPath(rootDir),
2924
+ JSON.stringify(marker, null, 2),
2925
+ "utf-8"
2926
+ );
2927
+ }
2928
+ async function isInitialized(rootDir) {
2929
+ const marker = await loadProjectMarker(rootDir);
2930
+ return marker !== null;
2931
+ }
2932
+ function uninitializedResponse(action) {
2933
+ return JSON.stringify(
2934
+ {
2935
+ initialized: false,
2936
+ hint: `This project hasn't been set up for Mason yet. Call \`mason_init\` first; it will walk the user through ${action}.`
2937
+ },
2938
+ null,
2939
+ 2
2940
+ );
2941
+ }
2942
+ var CLAUDE_MD_SECTION = `<!-- mason:start -->
2943
+ ## Mason concept map
2944
+
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:
2946
+
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.
2952
+
2953
+ Fall back to manual exploration only for what the map doesn't answer.
2954
+ <!-- mason:end -->`;
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.
2956
+
2957
+ CONSENT
2958
+ Tell the user: "Mason will read your codebase in batches and build a concept map at .mason/snapshot.json. This can take a while \u2014 Mason makes several tool calls in sequence. Proceed?"
2959
+ On no: stop. Mason setup is opt-in.
2960
+ On yes: continue with the phases below.
2961
+
2962
+ PHASE 1 \u2014 Map (loop until done)
2963
+ Goal: process every file in the codebase, batch by batch, producing a partial concept map per batch.
2964
+
2965
+ 1. Call \`generate_snapshot_batch(dir)\` (omit offset on the first call).
2966
+ The response includes:
2967
+ - \`batchId\`: identifier for this batch
2968
+ - \`offset\`, \`nextOffset\`, \`totalFiles\`: progress markers
2969
+ - \`instructions\`: the system prompt for the batch step
2970
+ - \`prompt\`: the files in this batch (skeletons + a few deeper bodies)
2971
+ 2. Following the \`instructions\`, derive features and flows that involve ONLY the files in this batch. Use product-natural feature names ("home screen", not "HomeScreenAndroid") so the reduce step can merge platform variants.
2972
+ 3. Call \`save_partial_snapshot(dir, batchId, offset, features, flows)\` to persist the partial.
2973
+ 4. If \`nextOffset\` is null, the Map phase is done. Otherwise call \`generate_snapshot_batch(dir, offset=nextOffset)\` and repeat from step 2.
2974
+
2975
+ Briefly tell the user "Batch N of M done" each iteration so they see progress.
2976
+
2977
+ CRITICAL RULES FOR PHASE 1:
2978
+ - Derive features and file paths ONLY from what appears verbatim in the \`prompt\` field of each batch response. NEVER invent paths from memory, prior projects, or what you assume a project of this kind would contain. If you have not seen a path in a batch \`prompt\`, do not put it in \`features.files\` or \`flows.chain\`.
2979
+ - Process batches SEQUENTIALLY: one \`generate_snapshot_batch\` \u2192 derive \u2192 one \`save_partial_snapshot\` \u2192 next \`generate_snapshot_batch\`. Do not parallelise. Do not call \`save_snapshot\` during this phase \u2014 that is a Phase 2 step.
2980
+ - You must walk every batch until \`nextOffset\` is null. Do not stop early. Do not skip ahead to reduce until every batch has been saved as a partial.
2981
+
2982
+ PHASE 2 \u2014 Reduce (once)
2983
+ Goal: merge all partial maps into one coherent product-shaped catalog.
2984
+
2985
+ 1. Call \`reduce_snapshot(dir)\`. It returns every partial map plus reconciliation instructions.
2986
+ 2. Follow the instructions to produce a unified \`features\` and \`flows\` map. Specifically: merge platform variants ("home Android" + "home iOS" \u2192 "home screen"), dedupe near-duplicates, reconcile descriptions, and ensure every file from every partial appears somewhere in the final map.
2987
+ 3. Call \`save_snapshot(dir, features, flows)\` ONCE with the unified map. Mason detects that partials exist and replaces the snapshot wholesale (rather than merging with any earlier state) and then clears the partials. Do not call \`save_snapshot\` more than once per Map-Reduce run.
2988
+
2989
+ PHASE 3 \u2014 Confluence sync (optional)
2990
+ Goal: optionally configure Confluence so the concept map can be exported as a product-readable wiki later.
2991
+
2992
+ Tell the user: "Mason can keep a Confluence wiki in sync with the concept map, rewriting it into product-readable language for PMs and designers. Want to set that up now? You can also skip and configure it later by asking your assistant to 'set up Confluence for this project'."
2993
+ On no: skip to Phase 4.
2994
+ On yes:
2995
+ 1. Ask the user for the Atlassian site URL (e.g. \`acme.atlassian.net\` or \`https://acme.atlassian.net\`).
2996
+ 2. Ask for the user's Atlassian account email.
2997
+ 3. Tell the user: "Generate an API token at https://id.atlassian.com/manage-profile/security/api-tokens (label it 'Mason') and paste it here. WARNING: the token will be visible in this chat history; if that's not acceptable, skip Confluence and configure it elsewhere."
2998
+ 4. Call \`mason_set_confluence({ baseUrl, email, apiToken })\` \u2014 no spaceKey on the first call. The tool validates credentials and returns a list of spaces.
2999
+ 5. Show the spaces to the user (key + name) and ask which one to use.
3000
+ 6. Call \`mason_set_confluence({ baseUrl, email, apiToken, spaceKey })\` with the chosen spaceKey to persist.
3001
+ 7. Confirm Confluence is configured. Mention they can run \`export_to_confluence\` whenever they want to sync.
3002
+
3003
+ If the credentials are rejected with a 401/403 the tool returns a friendly error \u2014 re-ask the user for a fresh token or correct email.
3004
+
3005
+ PHASE 4 \u2014 Assistant instructions (recommended)
3006
+ Goal: make sure future assistant sessions actually use the map instead of re-exploring.
3007
+
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?"
3009
+ On no: skip to Phase 5.
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:
3015
+
3016
+ ${CLAUDE_MD_SECTION}
3017
+
3018
+ PHASE 5 \u2014 Finalize
3019
+ 1. Call \`mason_complete_init(dir, { confluenceConfigured: true | false })\` \u2014 true if Phase 3 ended with status "saved", false otherwise.
3020
+ 2. Confirm to the user that setup is complete and they can now ask architectural questions, request impact analysis, or sync to Confluence (if configured).
3021
+
3022
+ Notes:
3023
+ - Read tools (\`get_snapshot\`, \`get_impact\`) refuse to run until \`mason_complete_init\` has been called. Do not skip Phase 5.
3024
+ - If the user aborts mid-flow, the partials persist in \`.mason/partial-snapshots/\`; the next \`mason_init\` run can pick up where it left off.
3025
+ - \`mason_init\` is idempotent \u2014 already-initialized projects return \`{ initialized: true, confluenceConfigured: ... }\`.
3026
+ - The user can reconfigure Confluence later by asking their assistant to call \`mason_set_confluence\` directly.`;
3027
+ function setupPlaybook() {
3028
+ return SETUP_PLAYBOOK;
3029
+ }
3030
+
3031
+ // src/mcp/tools.ts
3032
+ var exec9 = promisify9(execFile9);
3033
+ var IGNORE3 = [
3034
+ "**/node_modules/**",
3035
+ "**/dist/**",
3036
+ "**/build/**",
3037
+ "**/.gradle/**",
3038
+ "**/target/**",
3039
+ "**/.git/**",
3040
+ "**/vendor/**",
3041
+ "**/__pycache__/**",
3042
+ "**/venv/**",
3043
+ "**/.venv/**",
3044
+ "**/*.min.*",
3045
+ "**/*.map"
3046
+ ];
3047
+ async function buildContext(dir) {
3048
+ return {
3049
+ rootDir: dir,
3050
+ gitAvailable: await isGitRepo(dir)
3051
+ };
3052
+ }
3053
+ async function analyzeProject(dir) {
3054
+ const rootDir = path14.resolve(dir);
3055
+ const context = await buildContext(rootDir);
3056
+ const results = await runAll(context);
3057
+ const projectSnapshot = await detectProjectSnapshot(rootDir);
3058
+ const output = {
3059
+ project: projectSnapshot,
3060
+ analyzers: results.map((r) => ({
3061
+ name: r.analyzer,
3062
+ durationMs: r.durationMs,
3063
+ findings: r.findings.map((f) => ({
3064
+ category: f.category,
3065
+ confidence: f.confidence,
3066
+ summary: f.summary,
3067
+ evidence: f.evidence,
3068
+ suggestedRule: f.ruleCandidate
3069
+ })),
3070
+ gaps: r.gaps.map((g) => ({
3071
+ question: g.question,
3072
+ context: g.context
3073
+ }))
3074
+ }))
3075
+ };
3076
+ return JSON.stringify(output, null, 2);
3077
+ }
3078
+ async function detectProjectSnapshot(rootDir) {
3079
+ const buildFiles = [
3080
+ "package.json",
3081
+ "tsconfig.json",
3082
+ "build.gradle.kts",
3083
+ "build.gradle",
3084
+ "settings.gradle.kts",
3085
+ "settings.gradle",
3086
+ "gradle/libs.versions.toml",
3087
+ "Cargo.toml",
3088
+ "go.mod",
3089
+ "go.sum",
3090
+ "pyproject.toml",
3091
+ "setup.py",
3092
+ "requirements.txt",
3093
+ "Pipfile",
3094
+ "Gemfile",
3095
+ "Package.swift",
3096
+ "Makefile",
3097
+ "CMakeLists.txt",
3098
+ "Dockerfile",
3099
+ "docker-compose.yml",
3100
+ "docker-compose.yaml",
3101
+ ".github/workflows",
3102
+ ".gitlab-ci.yml",
3103
+ "Jenkinsfile"
3104
+ ];
3105
+ const present = [];
3106
+ for (const file of buildFiles) {
3107
+ try {
3108
+ await fs11.access(path14.join(rootDir, file));
3109
+ present.push(file);
3110
+ } catch {
3111
+ }
3112
+ }
3113
+ const testDirs = [
3114
+ "test",
3115
+ "tests",
3116
+ "__tests__",
3117
+ "spec",
3118
+ "src/test",
3119
+ "src/tests",
3120
+ "**/src/test",
3121
+ "**/src/androidTest",
3122
+ "**/src/iosTest"
3123
+ ];
3124
+ const testInfo = {};
3125
+ for (const pattern of testDirs) {
3126
+ const files = await fg6(`${pattern}/**/*`, {
3127
+ cwd: rootDir,
3128
+ ignore: IGNORE3,
3129
+ onlyFiles: true
3130
+ });
3131
+ if (files.length > 0) {
3132
+ testInfo[pattern] = files.length;
3133
+ }
3134
+ }
3135
+ const testFilePatterns = [
3136
+ { pattern: "**/*.test.*", label: "*.test.*" },
3137
+ { pattern: "**/*.spec.*", label: "*.spec.*" },
3138
+ { pattern: "**/*Test.kt", label: "*Test.kt" },
3139
+ { pattern: "**/*Test.java", label: "*Test.java" },
3140
+ { pattern: "**/test_*.py", label: "test_*.py" },
3141
+ { pattern: "**/*_test.go", label: "*_test.go" },
3142
+ { pattern: "**/*Tests.swift", label: "*Tests.swift" },
3143
+ { pattern: "**/*_test.rs", label: "*_test.rs" }
3144
+ ];
3145
+ for (const { pattern, label } of testFilePatterns) {
3146
+ const files = await fg6(pattern, { cwd: rootDir, ignore: IGNORE3 });
3147
+ if (files.length > 0) {
3148
+ testInfo[label] = files.length;
3149
+ }
3150
+ }
3151
+ const sourceFiles = await fg6("**/*.{ts,tsx,js,jsx,kt,kts,java,py,go,rs,swift,rb,cs,cpp,c,dart}", {
3152
+ cwd: rootDir,
3153
+ ignore: IGNORE3
3154
+ });
3155
+ const fileCounts = {};
3156
+ for (const file of sourceFiles) {
3157
+ const ext = path14.extname(file).slice(1);
3158
+ fileCounts[ext] = (fileCounts[ext] ?? 0) + 1;
3159
+ }
3160
+ return {
3161
+ configFilesPresent: present,
3162
+ sourceFileCounts: fileCounts,
3163
+ totalSourceFiles: sourceFiles.length,
3164
+ testInfo: Object.keys(testInfo).length > 0 ? testInfo : void 0
3165
+ };
3166
+ }
3167
+ async function getCodeSamples(dir, count = 15) {
3168
+ const rootDir = path14.resolve(dir);
3169
+ const samples = await sampleFiles(rootDir, count);
3170
+ const output = {
3171
+ note: "These are previews (first ~60 lines). Read the file directly with your own tools to see it in full.",
3172
+ files: samples.map((s) => ({
3173
+ path: s.path,
3174
+ reason: s.reason,
3175
+ totalLines: s.totalLines,
3176
+ sizeBytes: s.sizeBytes,
3177
+ preview: s.preview
3178
+ }))
3179
+ };
3180
+ return JSON.stringify(output, null, 2);
3181
+ }
3182
+ var UNINIT_MAX_DIRECTORIES = 40;
3183
+ var UNINIT_MAX_TEST_PAIRS = 30;
3184
+ async function uninitializedContextResponse(rootDir, action) {
3185
+ const [structureRaw, analyzerResults, testMap] = await Promise.all([
3186
+ getProjectStructure(rootDir),
3187
+ runAll(await buildContext(rootDir)).catch(() => []),
3188
+ Promise.resolve().then(() => (init_test_map(), test_map_exports)).then((m) => m.buildTestMap(rootDir)).catch(() => null)
3189
+ ]);
3190
+ const structure = JSON.parse(structureRaw);
3191
+ structure.directories = (structure.directories ?? []).sort(
3192
+ (a, b) => b.fileCount - a.fileCount
3193
+ ).slice(0, UNINIT_MAX_DIRECTORIES);
3194
+ const gitSignals = analyzerResults.flatMap(
3195
+ (r) => r.findings.map((f) => ({
3196
+ category: f.category,
3197
+ summary: f.summary,
3198
+ evidence: f.evidence.slice(0, 5)
3199
+ }))
3200
+ );
3201
+ return JSON.stringify({
3202
+ initialized: false,
3203
+ hint: `No Mason concept map exists here yet. Use the context below plus your own reads to answer now \u2014 then offer to set Mason up (\`mason_init\` walks the user through ${action}); don't start setup unprompted.`,
3204
+ structure,
3205
+ gitSignals,
3206
+ testPairs: testMap?.paired?.slice(0, UNINIT_MAX_TEST_PAIRS) ?? []
3207
+ });
3208
+ }
3209
+ async function getProjectStructure(dir) {
3210
+ const rootDir = path14.resolve(dir);
3211
+ const allFiles = await fg6("**/*", {
3212
+ cwd: rootDir,
3213
+ ignore: IGNORE3,
3214
+ onlyFiles: true
3215
+ });
3216
+ const dirInfo = /* @__PURE__ */ new Map();
3217
+ for (const file of allFiles) {
3218
+ const parts = file.split("/");
3219
+ for (let depth = 1; depth <= Math.min(parts.length, 2); depth++) {
3220
+ const dirPath = parts.slice(0, depth).join("/");
3221
+ if (!dirInfo.has(dirPath)) {
3222
+ dirInfo.set(dirPath, { fileCount: 0, extensions: /* @__PURE__ */ new Map() });
3223
+ }
3224
+ const info = dirInfo.get(dirPath);
3225
+ info.fileCount++;
3226
+ const ext = path14.extname(file).slice(1);
3227
+ if (ext) {
3228
+ info.extensions.set(ext, (info.extensions.get(ext) ?? 0) + 1);
3229
+ }
3230
+ }
3231
+ }
3232
+ const directories = [...dirInfo.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([dirPath, info]) => {
3233
+ const extensions = {};
3234
+ for (const [ext, count] of info.extensions) {
3235
+ extensions[ext] = count;
3236
+ }
3237
+ return { path: dirPath, fileCount: info.fileCount, extensions };
3238
+ });
3239
+ const topLevelFiles = allFiles.filter((f) => !f.includes("/"));
3240
+ const output = {
3241
+ totalFiles: allFiles.length,
3242
+ topLevelFiles,
3243
+ directories
3244
+ };
3245
+ return JSON.stringify(output, null, 2);
3246
+ }
3247
+ async function getTestMap(dir) {
3248
+ const { buildTestMap: buildTestMap2 } = await Promise.resolve().then(() => (init_test_map(), test_map_exports));
3249
+ const result = await buildTestMap2(dir);
3250
+ return JSON.stringify(result, null, 2);
3251
+ }
3252
+ var STALE_DIFF_PREVIEW_LINES = 60;
3253
+ var STALE_DIFF_MAX_FILES = 25;
3254
+ async function buildChangedFilePreviews(rootDir, changedFiles) {
3255
+ const capped = changedFiles.slice(0, STALE_DIFF_MAX_FILES);
3256
+ const previews = [];
3257
+ for (const filePath of capped) {
3258
+ const full = await readFullFile(rootDir, filePath);
3259
+ if (!full) continue;
3260
+ const lines = full.content.split("\n");
3261
+ previews.push({
3262
+ path: full.path,
3263
+ totalLines: full.totalLines,
3264
+ preview: lines.slice(0, STALE_DIFF_PREVIEW_LINES).join("\n")
3265
+ });
3266
+ }
3267
+ return previews;
3268
+ }
3269
+ async function getSnapshot(dir) {
3270
+ const rootDir = path14.resolve(dir);
3271
+ if (!await isInitialized(rootDir)) {
3272
+ return uninitializedContextResponse(rootDir, "building the concept map");
3273
+ }
3274
+ const snapshot = await loadSnapshot(rootDir);
3275
+ if (!snapshot) {
3276
+ return JSON.stringify({
3277
+ exists: false,
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)."
3279
+ });
3280
+ }
3281
+ const drift = await computeDrift(rootDir);
3282
+ const isStale = drift?.stale ?? false;
3283
+ const seenFiles = /* @__PURE__ */ new Set();
3284
+ const compactFeatures = {};
3285
+ for (const [name, feat] of Object.entries(snapshot.features)) {
3286
+ const unique = feat.files.filter((f) => !seenFiles.has(f));
3287
+ if (unique.length === 0) continue;
3288
+ for (const f of unique) seenFiles.add(f);
3289
+ const entry = {
3290
+ files: unique,
3291
+ type: normalizeFeatureType(feat.type)
3292
+ };
3293
+ if (feat.tests && feat.tests.length > 0) {
3294
+ entry.tests = feat.tests;
3295
+ }
3296
+ compactFeatures[name] = entry;
3297
+ }
3298
+ const compactFlows = {};
3299
+ for (const [name, flow] of Object.entries(snapshot.flows)) {
3300
+ compactFlows[name] = flow.chain;
3301
+ }
3302
+ const output = {
3303
+ exists: true,
3304
+ updatedAt: snapshot.updatedAt,
3305
+ features: compactFeatures,
3306
+ flows: compactFlows,
3307
+ stale: isStale
3308
+ };
3309
+ const { loadDecisions: loadDecisions2 } = await Promise.resolve().then(() => (init_decisions(), decisions_exports));
3310
+ const decisionRecords = await loadDecisions2(rootDir);
3311
+ if (decisionRecords.length > 0) {
3312
+ const compactDecisions = {};
3313
+ for (const d of decisionRecords) {
3314
+ if (d.status !== "active") continue;
3315
+ compactDecisions[d.id] = {
3316
+ title: d.title,
3317
+ category: d.category,
3318
+ files: d.files
3319
+ };
3320
+ }
3321
+ output.decisions = compactDecisions;
3322
+ output.decisionsHint = "Recorded team knowledge \u2014 get_context returns matching full bodies; records live at .mason/decisions/<id>.json.";
3323
+ }
3324
+ if (isStale && drift) {
3325
+ output.hint = driftHint(drift);
3326
+ if (drift.historyAvailable && drift.changedFiles.length > 0) {
3327
+ const samples = await buildChangedFilePreviews(
3328
+ rootDir,
3329
+ drift.changedFiles
3330
+ );
3331
+ output.diff = {
3332
+ changedFiles: drift.changedFiles,
3333
+ samples,
3334
+ truncated: drift.changedFiles.length > STALE_DIFF_MAX_FILES
3335
+ };
3336
+ output.drift = {
3337
+ staleFeatures: drift.staleFeatures,
3338
+ staleFlows: drift.staleFlows,
3339
+ unmappedFiles: drift.unmappedFiles,
3340
+ ghostFiles: drift.ghostFiles,
3341
+ renames: drift.renames,
3342
+ recommendation: drift.recommendation
3343
+ };
3344
+ }
3345
+ }
3346
+ return JSON.stringify(output);
3347
+ }
3348
+ function driftHint(report) {
3349
+ if (!report.stale) {
3350
+ return "Snapshot matches HEAD. No action needed.";
3351
+ }
3352
+ if (!report.historyAvailable) {
3353
+ return "Snapshot is stale but its commit is unreachable (shallow clone or rewritten history), so per-feature drift cannot be computed. Re-run the Map-Reduce build: generate_snapshot_batch \u2192 save_partial_snapshot \u2192 reduce_snapshot \u2192 save_snapshot.";
3354
+ }
3355
+ if (report.recommendation === "full-rebuild") {
3356
+ return "Drift is too large for an incremental update. Re-run the Map-Reduce build: generate_snapshot_batch \u2192 save_partial_snapshot \u2192 reduce_snapshot \u2192 save_snapshot.";
3357
+ }
3358
+ if (report.changedFiles.length + report.unmappedFiles.length > STALE_DIFF_MAX_FILES) {
3359
+ return "Many files drifted \u2014 use a scoped refresh instead of reading them all inline: call generate_snapshot_batch(dir, files=[...changedFiles, ...unmappedFiles]) repeatedly (same list every call) with save_partial_snapshot per batch, then reduce_snapshot and save_snapshot. Entries untouched by the drift are preserved in the reduce step.";
3360
+ }
3361
+ const nothingToRemap = Object.keys(report.staleFeatures).length === 0 && Object.keys(report.staleFlows).length === 0 && report.unmappedFiles.length === 0 && report.ghostFiles.length === 0;
3362
+ if (nothingToRemap) {
3363
+ return "Changes since the snapshot don't touch any mapped files. Call save_snapshot with empty features/flows to re-pin the snapshot to HEAD.";
3364
+ }
3365
+ return "Read the changed files under staleFeatures/staleFlows, update those entries (fold unmappedFiles into the right features), and call save_snapshot with only the affected entries \u2014 unchanged entries are preserved. Drop ghostFiles from any entries that reference them, and delete features/flows that no longer exist via save_snapshot's removeFeatures/removeFlows.";
3366
+ }
3367
+ async function checkDrift(dir) {
3368
+ const rootDir = path14.resolve(dir);
3369
+ if (!await isInitialized(rootDir)) {
3370
+ return uninitializedResponse("checking concept-map drift");
3371
+ }
3372
+ const report = await computeDrift(rootDir);
3373
+ if (!report) {
3374
+ return JSON.stringify({
3375
+ exists: false,
3376
+ hint: "No concept map exists yet. Build one first: generate_snapshot_batch \u2192 save_partial_snapshot \u2192 reduce_snapshot \u2192 save_snapshot."
3377
+ });
3378
+ }
3379
+ const snapshot = await loadSnapshot(rootDir);
3380
+ let verification;
3381
+ let hint = driftHint(report);
3382
+ if (snapshot) {
3383
+ const all = [
3384
+ ...Object.values(snapshot.features),
3385
+ ...Object.values(snapshot.flows)
3386
+ ];
3387
+ const failedNames = [
3388
+ ...Object.entries(snapshot.features).filter(([, e]) => e.verificationFailed).map(([n]) => n),
3389
+ ...Object.entries(snapshot.flows).filter(([, e]) => e.verificationFailed).map(([n]) => n)
3390
+ ];
3391
+ verification = {
3392
+ neverVerified: all.filter((e) => !e.verifiedAt).length,
3393
+ failed: failedNames
3394
+ };
3395
+ if (failedNames.length > 0) {
3396
+ hint += ` Verification previously FAILED for [${failedNames.join(", ")}] \u2014 re-map those entries before trusting them.`;
3397
+ }
3398
+ }
3399
+ return JSON.stringify({ exists: true, ...report, verification, hint });
3400
+ }
3401
+ async function generateSnapshotBatch(dir, offset = 0, batchSize = DEFAULT_BATCH_SIZE, files) {
3402
+ const rootDir = path14.resolve(dir);
3403
+ const scoped = files !== void 0 && files.length > 0;
3404
+ const scopeFiles = scoped ? sanitizePaths(rootDir, files) : void 0;
3405
+ const batch = await prepareSnapshotBatch(dir, offset, batchSize, scopeFiles);
3406
+ if (scoped && batch.totalFiles > 0) {
3407
+ await saveScope(rootDir, scopeFiles);
3408
+ } else if (!scoped) {
3409
+ await clearScope(rootDir);
3410
+ }
3411
+ const task = scoped ? "Refresh the concept map for a scoped set of drifted files (batch step)." : "Build a concept-to-files map for this project (batch step).";
3412
+ if (batch.totalFiles === 0) {
3413
+ return JSON.stringify(
3414
+ {
3415
+ task,
3416
+ offset: 0,
3417
+ nextOffset: null,
3418
+ totalFiles: 0,
3419
+ batchId: batchIdFor(0),
3420
+ instructions: BATCH_SYSTEM_PROMPT,
3421
+ prompt: scoped ? "(None of the requested files exist as source files in this project.)" : "(No source files found to map.)",
3422
+ next: scoped ? "None of the requested files matched project source files. Check the paths passed in `files` \u2014 they must be repo-relative." : "No source files were found. Skip the rest of the playbook and call mason_complete_init."
3423
+ },
3424
+ null,
3425
+ 2
3426
+ );
3427
+ }
3428
+ const batchId = batchIdFor(batch.offset);
3429
+ const continueCall = scoped ? `generate_snapshot_batch(dir, offset=${batch.nextOffset}, files=<the same list>)` : `generate_snapshot_batch(dir, offset=${batch.nextOffset})`;
3430
+ return JSON.stringify(
3431
+ {
3432
+ task,
3433
+ offset: batch.offset,
3434
+ nextOffset: batch.nextOffset,
3435
+ totalFiles: batch.totalFiles,
3436
+ batchId,
3437
+ batchSize: batch.batchSize,
3438
+ filesInBatch: batch.skeletons.length,
3439
+ scoped,
3440
+ instructions: BATCH_SYSTEM_PROMPT,
3441
+ prompt: buildBatchPrompt(batch),
3442
+ next: batch.nextOffset === null ? `Derive partial features/flows for this batch and call save_partial_snapshot(dir, batchId="${batchId}", features, flows). This is the last batch \u2014 after saving, proceed to reduce_snapshot.` : `Derive partial features/flows for this batch and call save_partial_snapshot(dir, batchId="${batchId}", features, flows). Then call ${continueCall} to continue.`
3443
+ },
3444
+ null,
3445
+ 2
3446
+ );
3447
+ }
3448
+ async function saveSnapshotPartial(dir, batchId, offset, features, flows) {
3449
+ const rootDir = path14.resolve(dir);
3450
+ for (const feat of Object.values(features)) {
3451
+ feat.files = sanitizePaths(rootDir, feat.files);
3452
+ if (feat.tests) feat.tests = sanitizePaths(rootDir, feat.tests);
3453
+ feat.type = normalizeFeatureType(feat.type);
3454
+ }
3455
+ for (const flow of Object.values(flows)) {
3456
+ flow.chain = sanitizePaths(rootDir, flow.chain);
3457
+ }
3458
+ await savePartial(rootDir, {
3459
+ batchId,
3460
+ offset,
3461
+ features,
3462
+ flows,
3463
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
3464
+ });
3465
+ const all = await loadAllPartials(rootDir);
3466
+ return JSON.stringify(
3467
+ {
3468
+ status: "stored",
3469
+ batchId,
3470
+ partialsStored: all.length,
3471
+ hint: "Partial saved. Continue with the next generate_snapshot_batch call, or proceed to reduce_snapshot when nextOffset is null."
3472
+ },
3473
+ null,
3474
+ 2
3475
+ );
3476
+ }
3477
+ async function reduceSnapshot(dir) {
3478
+ const rootDir = path14.resolve(dir);
3479
+ const partials = await loadAllPartials(rootDir);
3480
+ if (partials.length === 0) {
3481
+ return JSON.stringify(
3482
+ {
3483
+ status: "error",
3484
+ error: "No partial snapshots found. Run generate_snapshot_batch and save_partial_snapshot at least once before calling reduce_snapshot."
3485
+ },
3486
+ null,
3487
+ 2
3488
+ );
3489
+ }
3490
+ const scope = await loadScope(rootDir);
3491
+ const existing = scope && scope.length > 0 ? await loadSnapshot(rootDir) : null;
3492
+ if (scope && existing) {
3493
+ const cleanFeatures = Object.fromEntries(
3494
+ Object.entries(existing.features).map(([name, feat]) => [
3495
+ name,
3496
+ {
3497
+ description: feat.description,
3498
+ files: feat.files,
3499
+ ...feat.tests && feat.tests.length > 0 ? { tests: feat.tests } : {}
3500
+ }
3501
+ ])
3502
+ );
3503
+ const cleanFlows = Object.fromEntries(
3504
+ Object.entries(existing.flows).map(([name, flow]) => [
3505
+ name,
3506
+ { description: flow.description, chain: flow.chain }
3507
+ ])
3508
+ );
3509
+ return JSON.stringify(
3510
+ {
3511
+ task: "Merge a scoped refresh into the existing concept map.",
3512
+ partialsCount: partials.length,
3513
+ refreshedFiles: scope.length,
3514
+ instructions: REFRESH_REDUCE_SYSTEM_PROMPT,
3515
+ prompt: buildRefreshReducePrompt(
3516
+ { features: cleanFeatures, flows: cleanFlows },
3517
+ scope,
3518
+ partials
3519
+ ),
3520
+ next: "Follow `instructions` to produce the COMPLETE updated features/flows (entries untouched by the refresh copied through unchanged), then call save_snapshot(dir, features, flows). Partials and the scope marker are cleaned up automatically after save_snapshot succeeds."
3521
+ },
3522
+ null,
3523
+ 2
3524
+ );
3525
+ }
3526
+ return JSON.stringify(
3527
+ {
3528
+ task: "Merge partial concept maps into one unified map.",
3529
+ partialsCount: partials.length,
3530
+ instructions: REDUCE_SYSTEM_PROMPT,
3531
+ prompt: buildReducePrompt(partials),
3532
+ next: "Follow `instructions` to produce the unified features/flows, then call save_snapshot(dir, features, flows). Partial files will be cleaned up automatically after save_snapshot succeeds. Finish with mason_complete_init(dir)."
3533
+ },
3534
+ null,
3535
+ 2
3536
+ );
3537
+ }
3538
+ async function fullAnalysis(dir) {
3539
+ const rootDir = path14.resolve(dir);
3540
+ const [analysis, structure, samples, testMap, snapshot] = await Promise.all([
3541
+ analyzeProject(dir),
3542
+ getProjectStructure(dir),
3543
+ getCodeSamples(dir, 25),
3544
+ getTestMap(dir),
3545
+ loadSnapshot(rootDir)
3546
+ ]);
3547
+ const output = {
3548
+ note: "Full project analysis. Code samples are previews (~60 lines). Read files directly with your own tools to see them in full.",
3549
+ analysis: JSON.parse(analysis),
3550
+ structure: JSON.parse(structure),
3551
+ codeSamples: JSON.parse(samples),
3552
+ testMap: JSON.parse(testMap)
3553
+ };
3554
+ if (snapshot) {
3555
+ output.conceptMap = {
3556
+ updatedAt: snapshot.updatedAt,
3557
+ features: snapshot.features,
3558
+ flows: snapshot.flows
3559
+ };
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.";
3561
+ }
3562
+ return JSON.stringify(output, null, 2);
3563
+ }
3564
+ function sanitizePaths(rootDir, files) {
3565
+ return files.filter((f) => {
3566
+ const resolved = path14.resolve(rootDir, f);
3567
+ return resolved.startsWith(rootDir) && !f.startsWith("/") && !f.includes("..");
3568
+ });
3569
+ }
3570
+ async function saveSnapshotData(dir, features, flows, removeFeatures = [], removeFlows = []) {
3571
+ const rootDir = path14.resolve(dir);
3572
+ const gitHash = await getCurrentGitHash(rootDir);
3573
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3574
+ for (const feat of Object.values(features)) {
3575
+ feat.files = sanitizePaths(rootDir, feat.files);
3576
+ if (feat.tests) feat.tests = sanitizePaths(rootDir, feat.tests);
3577
+ feat.type = normalizeFeatureType(feat.type);
3578
+ }
3579
+ for (const flow of Object.values(flows)) {
3580
+ flow.chain = sanitizePaths(rootDir, flow.chain);
3581
+ }
3582
+ const partials = await loadAllPartials(rootDir);
3583
+ const replaceMode = partials.length > 0;
3584
+ const existing = replaceMode ? null : await loadSnapshot(rootDir);
3585
+ if (existing) {
3586
+ if (existing.gitHash !== "unknown") {
3587
+ for (const feat of Object.values(existing.features)) {
3588
+ feat.refreshedHash ??= existing.gitHash;
3589
+ }
3590
+ for (const flow of Object.values(existing.flows)) {
3591
+ flow.refreshedHash ??= existing.gitHash;
3592
+ }
3593
+ }
3594
+ const removedFeatures = removeFeatures.filter(
3595
+ (name) => name in existing.features
3596
+ );
3597
+ const removedFlows = removeFlows.filter((name) => name in existing.flows);
3598
+ for (const name of removedFeatures) delete existing.features[name];
3599
+ for (const name of removedFlows) delete existing.flows[name];
3600
+ if (gitHash !== "unknown") {
3601
+ for (const feat of Object.values(features)) feat.refreshedHash = gitHash;
3602
+ for (const flow of Object.values(flows)) flow.refreshedHash = gitHash;
3603
+ }
3604
+ existing.features = { ...existing.features, ...features };
3605
+ existing.flows = { ...existing.flows, ...flows };
3606
+ existing.updatedAt = now;
3607
+ existing.gitHash = gitHash;
3608
+ await saveSnapshot(rootDir, existing);
3609
+ await clearAllPartials(rootDir);
3610
+ return JSON.stringify({
3611
+ status: "updated",
3612
+ mode: "merged",
3613
+ features: Object.keys(existing.features).length,
3614
+ flows: Object.keys(existing.flows).length,
3615
+ removedFeatures: removedFeatures.length,
3616
+ removedFlows: removedFlows.length
3617
+ });
3618
+ }
3619
+ const snapshot = {
3620
+ version: 2,
3621
+ createdAt: now,
3622
+ updatedAt: now,
3623
+ gitHash,
3624
+ features,
3625
+ flows
3626
+ };
3627
+ await saveSnapshot(rootDir, snapshot);
3628
+ await clearAllPartials(rootDir);
3629
+ return JSON.stringify({
3630
+ status: replaceMode ? "replaced" : "created",
3631
+ mode: replaceMode ? "replaced-from-partials" : "fresh",
3632
+ features: Object.keys(features).length,
3633
+ flows: Object.keys(flows).length
3634
+ });
3635
+ }
3636
+ async function getImpact(dir, files) {
3637
+ const rootDir = path14.resolve(dir);
3638
+ if (!await isInitialized(rootDir)) {
3639
+ return uninitializedContextResponse(rootDir, "analyzing change impact");
3640
+ }
3641
+ const { analyzeImpact: analyzeImpact2 } = await Promise.resolve().then(() => (init_impact(), impact_exports));
3642
+ const result = await analyzeImpact2(rootDir, files);
3643
+ return JSON.stringify(result, null, 2);
3644
+ }
3645
+ var VERIFY_DEFAULT_SAMPLE = 5;
3646
+ var VERIFY_MAX_FILES_PER_ENTRY = 8;
3647
+ var VERIFY_SKELETON_CHARS = 500;
3648
+ async function verifySnapshot(dir, sample = VERIFY_DEFAULT_SAMPLE) {
3649
+ const rootDir = path14.resolve(dir);
3650
+ if (!await isInitialized(rootDir)) {
3651
+ return uninitializedResponse("verifying the concept map");
3652
+ }
3653
+ const snapshot = await loadSnapshot(rootDir);
3654
+ if (!snapshot) {
3655
+ return JSON.stringify({
3656
+ exists: false,
3657
+ hint: "No concept map exists yet \u2014 nothing to verify."
3658
+ });
3659
+ }
3660
+ const entries = [
3661
+ ...Object.entries(snapshot.features).map(([name, e]) => ({
3662
+ name,
3663
+ kind: "feature",
3664
+ description: e.description,
3665
+ files: e.files,
3666
+ verifiedAt: e.verifiedAt
3667
+ })),
3668
+ ...Object.entries(snapshot.flows).map(([name, e]) => ({
3669
+ name,
3670
+ kind: "flow",
3671
+ description: e.description,
3672
+ files: e.chain,
3673
+ verifiedAt: e.verifiedAt
3674
+ }))
3675
+ ];
3676
+ entries.sort((a, b) => {
3677
+ if (!a.verifiedAt && !b.verifiedAt) return a.name.localeCompare(b.name);
3678
+ if (!a.verifiedAt) return -1;
3679
+ if (!b.verifiedAt) return 1;
3680
+ return a.verifiedAt.localeCompare(b.verifiedAt);
3681
+ });
3682
+ const picked = entries.slice(0, Math.max(1, sample));
3683
+ const toVerify = [];
3684
+ for (const entry of picked) {
3685
+ const skeletons = [];
3686
+ for (const filePath of entry.files.slice(0, VERIFY_MAX_FILES_PER_ENTRY)) {
3687
+ const full = await readFullFile(rootDir, filePath);
3688
+ if (full) {
3689
+ skeletons.push({
3690
+ path: full.path,
3691
+ content: full.content.slice(0, VERIFY_SKELETON_CHARS)
3692
+ });
3693
+ } else {
3694
+ skeletons.push({ path: filePath, missing: true });
3695
+ }
3696
+ }
3697
+ toVerify.push({
3698
+ name: entry.name,
3699
+ kind: entry.kind,
3700
+ description: entry.description,
3701
+ lastVerified: entry.verifiedAt ?? "never",
3702
+ skeletons,
3703
+ truncated: entry.files.length > VERIFY_MAX_FILES_PER_ENTRY
3704
+ });
3705
+ }
3706
+ const neverVerified = entries.filter((e) => !e.verifiedAt).length;
3707
+ return JSON.stringify({
3708
+ exists: true,
3709
+ totalEntries: entries.length,
3710
+ neverVerified,
3711
+ entries: toVerify,
3712
+ instructions: 'For each entry, judge from the skeletons whether the listed files actually implement the claimed feature/flow (missing files count against it). Then call save_verification with verdicts: {"<entry name>": {"ok": true|false, "note": "<one line, required when ok is false>"}}. Be skeptical \u2014 a plausible description is not evidence; the files must show it.'
3713
+ });
3714
+ }
3715
+ async function saveVerification(dir, verdicts) {
3716
+ const rootDir = path14.resolve(dir);
3717
+ if (!await isInitialized(rootDir)) {
3718
+ return uninitializedResponse("saving verification verdicts");
3719
+ }
3720
+ const snapshot = await loadSnapshot(rootDir);
3721
+ if (!snapshot) {
3722
+ return JSON.stringify({ exists: false, hint: "No concept map exists." });
3723
+ }
3724
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3725
+ const stamped = [];
3726
+ const unknown = [];
3727
+ const failed = [];
3728
+ for (const [name, verdict] of Object.entries(verdicts)) {
3729
+ const entry = snapshot.features[name] ?? snapshot.flows[name];
3730
+ if (!entry) {
3731
+ unknown.push(name);
3732
+ continue;
3733
+ }
3734
+ entry.verifiedAt = now;
3735
+ if (verdict.ok) {
3736
+ delete entry.verificationFailed;
3737
+ delete entry.verificationNote;
3738
+ } else {
3739
+ entry.verificationFailed = true;
3740
+ entry.verificationNote = verdict.note ?? "verification failed";
3741
+ failed.push(name);
3742
+ }
3743
+ stamped.push(name);
3744
+ }
3745
+ snapshot.updatedAt = now;
3746
+ await saveSnapshot(rootDir, snapshot);
3747
+ return JSON.stringify({
3748
+ stamped,
3749
+ unknown,
3750
+ failed,
3751
+ hint: failed.length > 0 ? `Entries [${failed.join(", ")}] are mis-mapped. Re-map them: read their actual files, correct the entries, and call save_snapshot with only those entries (plus removeFeatures/removeFlows if a concept no longer exists).` : "All sampled entries verified. Re-run verify_snapshot periodically \u2014 it always picks the least-recently-verified entries next."
3752
+ });
3753
+ }
3754
+ async function saveDecision(dir, input) {
3755
+ const rootDir = path14.resolve(dir);
3756
+ if (!await isInitialized(rootDir)) {
3757
+ return uninitializedResponse("recording team decisions");
3758
+ }
3759
+ const { upsertDecision: upsertDecision2 } = await Promise.resolve().then(() => (init_decisions(), decisions_exports));
3760
+ const result = await upsertDecision2(rootDir, input);
3761
+ return JSON.stringify(result);
3762
+ }
3763
+ async function getContext(dir, task, files) {
3764
+ const rootDir = path14.resolve(dir);
3765
+ if (!await isInitialized(rootDir)) {
3766
+ return uninitializedContextResponse(rootDir, "assembling task context");
3767
+ }
3768
+ const { assembleContext: assembleContext2 } = await Promise.resolve().then(() => (init_assemble(), assemble_exports));
3769
+ const bundle = await assembleContext2(rootDir, task, files);
3770
+ if (!bundle) {
3771
+ return JSON.stringify({
3772
+ exists: false,
3773
+ hint: "No concept map exists yet. Build one first: generate_snapshot_batch \u2192 save_partial_snapshot \u2192 reduce_snapshot \u2192 save_snapshot."
3774
+ });
3775
+ }
3776
+ return JSON.stringify(bundle);
3777
+ }
3778
+ async function masonInit(dir) {
3779
+ const rootDir = path14.resolve(dir);
3780
+ const marker = await loadProjectMarker(rootDir);
3781
+ if (marker) {
3782
+ return JSON.stringify(
3783
+ {
3784
+ initialized: true,
3785
+ initializedAt: marker.initializedAt,
3786
+ confluenceConfigured: marker.features?.confluence === true,
3787
+ hint: "This project is already set up for Mason. To refresh the concept map, call generate_snapshot_batch. To (re)configure Confluence, call mason_set_confluence directly."
3788
+ },
3789
+ null,
3790
+ 2
3791
+ );
3792
+ }
3793
+ return JSON.stringify(
3794
+ {
3795
+ initialized: false,
3796
+ playbook: setupPlaybook()
3797
+ },
3798
+ null,
3799
+ 2
3800
+ );
3801
+ }
3802
+ async function masonCompleteInit(dir, options = {}) {
3803
+ const rootDir = path14.resolve(dir);
3804
+ const marker = {
3805
+ version: 1,
3806
+ initializedAt: (/* @__PURE__ */ new Date()).toISOString(),
3807
+ features: {
3808
+ confluence: options.confluenceConfigured ?? false
3809
+ }
3810
+ };
3811
+ await saveProjectMarker(rootDir, marker);
3812
+ return JSON.stringify(
3813
+ {
3814
+ status: "initialized",
3815
+ marker,
3816
+ hint: "Setup complete. Future calls to other Mason tools will work normally."
3817
+ },
3818
+ null,
3819
+ 2
3820
+ );
3821
+ }
3822
+ async function masonSetConfluence(input) {
3823
+ const { createConfluenceClient: createConfluenceClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
3824
+ const { saveConfluenceConfig: saveConfluenceConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
3825
+ const { normalizeAtlassianBaseUrl: normalizeAtlassianBaseUrl2 } = await Promise.resolve().then(() => (init_url(), url_exports));
3826
+ let baseUrl;
3827
+ try {
3828
+ baseUrl = normalizeAtlassianBaseUrl2(input.baseUrl);
3829
+ } catch (err) {
3830
+ return JSON.stringify({
3831
+ status: "error",
3832
+ error: err instanceof Error ? err.message : String(err)
3833
+ });
3834
+ }
3835
+ if (!input.email.includes("@")) {
3836
+ return JSON.stringify({
3837
+ status: "error",
3838
+ error: `Email looks invalid: "${input.email}".`
3839
+ });
3840
+ }
3841
+ if (!input.apiToken.trim()) {
3842
+ return JSON.stringify({
3843
+ status: "error",
3844
+ error: "API token is required."
3845
+ });
3846
+ }
3847
+ const probeConfig = {
3848
+ baseUrl,
3849
+ email: input.email,
3850
+ apiToken: input.apiToken,
3851
+ spaceKey: input.spaceKey ?? "",
3852
+ parentPageId: input.parentPageId
3853
+ };
3854
+ const client = createConfluenceClient2(probeConfig);
3855
+ let spaces;
3856
+ try {
3857
+ spaces = await client.listSpaces();
3858
+ } catch (err) {
3859
+ const msg = err instanceof Error ? err.message : String(err);
3860
+ if (msg.includes("401") || msg.includes("403")) {
3861
+ return JSON.stringify({
3862
+ status: "error",
3863
+ error: "Credentials rejected by Confluence. Re-check the email and that the API token hasn't expired or been revoked."
3864
+ });
3865
+ }
3866
+ return JSON.stringify({
3867
+ status: "error",
3868
+ error: `Confluence validation failed: ${msg}`
3869
+ });
3870
+ }
3871
+ if (!input.spaceKey) {
3872
+ return JSON.stringify(
3873
+ {
3874
+ status: "spaces_listed",
3875
+ baseUrl,
3876
+ spaces: spaces.map((s) => ({ key: s.key, name: s.name })),
3877
+ hint: spaces.length === 0 ? "Authenticated, but no spaces are visible to this account. Create one in Confluence first, then re-run mason_set_confluence." : "Ask the user which space to use, then call mason_set_confluence again with the same baseUrl/email/apiToken plus the chosen spaceKey."
3878
+ },
3879
+ null,
3880
+ 2
3881
+ );
3882
+ }
3883
+ const match = spaces.find((s) => s.key === input.spaceKey);
3884
+ if (!match) {
3885
+ return JSON.stringify({
3886
+ status: "error",
3887
+ error: `Space key "${input.spaceKey}" was not found among the spaces visible to this account. Available keys: ${spaces.map((s) => s.key).join(", ") || "(none)"}.`
3888
+ });
3889
+ }
3890
+ await saveConfluenceConfig2({
3891
+ baseUrl,
3892
+ email: input.email,
3893
+ apiToken: input.apiToken,
3894
+ spaceKey: input.spaceKey,
3895
+ parentPageId: input.parentPageId
3896
+ });
3897
+ return JSON.stringify(
3898
+ {
3899
+ status: "saved",
3900
+ spaceKey: input.spaceKey,
3901
+ spaceName: match.name,
3902
+ hint: "Confluence is configured. The credentials are stored in ~/.mason/config.json. Call export_to_confluence to sync the concept map."
3903
+ },
3904
+ null,
3905
+ 2
3906
+ );
3907
+ }
3908
+ async function exportToConfluenceTool(dir, overrides) {
3909
+ const rootDir = path14.resolve(dir);
3910
+ if (!await isInitialized(rootDir)) {
3911
+ return uninitializedResponse("syncing to Confluence");
3912
+ }
3913
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
3914
+ const { exportToConfluence: exportToConfluence2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
3915
+ const config = await loadConfig2();
3916
+ if (!config?.confluence) {
3917
+ return JSON.stringify({
3918
+ status: "error",
3919
+ error: "No Confluence credentials configured. Call mason_set_confluence first (or re-run mason_init and walk through the Confluence section)."
3920
+ });
3921
+ }
3922
+ const merged = {
3923
+ ...config,
3924
+ confluence: {
3925
+ ...config.confluence,
3926
+ spaceKey: overrides?.spaceKey ?? config.confluence.spaceKey,
3927
+ parentPageId: overrides?.parentPageId ?? config.confluence.parentPageId
3928
+ }
3929
+ };
3930
+ try {
3931
+ const summary = await exportToConfluence2(rootDir, merged, {
3932
+ indexPageTitle: overrides?.indexPageTitle,
3933
+ changelogPageTitle: overrides?.changelogPageTitle,
3934
+ featurePagePrefix: overrides?.featurePagePrefix
3935
+ });
3936
+ return JSON.stringify({ status: "ok", ...summary }, null, 2);
3937
+ } catch (err) {
3938
+ return JSON.stringify({
3939
+ status: "error",
3940
+ error: err instanceof Error ? err.message : String(err)
3941
+ });
3942
+ }
3943
+ }
3944
+
3945
+ // src/mcp/server.ts
3946
+ function createMcpServer() {
3947
+ const server = new McpServer(
3948
+ {
3949
+ name: "mason",
3950
+ version: "0.7.0"
3951
+ },
3952
+ {
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."
3954
+ }
3955
+ );
3956
+ server.tool(
3957
+ "mason_init",
3958
+ "Start here. Checks if Mason is set up for this project. If not, returns a `playbook` of questions the assistant must walk the user through (concept map + optional Confluence sync). Once the walkthrough is done, call `mason_complete_init`. Idempotent: re-running on an already-initialized project just returns the current state.",
3959
+ {
3960
+ dir: z.string().describe("Absolute path to the project root directory")
3961
+ },
3962
+ async ({ dir }) => {
3963
+ const result = await masonInit(dir);
3964
+ return { content: [{ type: "text", text: result }] };
3965
+ }
3966
+ );
3967
+ server.tool(
3968
+ "mason_complete_init",
3969
+ "Mark the project as initialized. Call this after walking the user through the playbook returned by `mason_init`. Writes `.mason/project.json` so future tool calls don't re-run the wizard. Pass `confluenceConfigured: true` if Phase 3 of the playbook ended with Confluence credentials saved.",
3970
+ {
3971
+ dir: z.string().describe("Absolute path to the project root directory"),
3972
+ confluenceConfigured: z.boolean().optional().default(false).describe("True if Confluence was successfully configured during init")
3973
+ },
3974
+ async ({ dir, confluenceConfigured }) => {
3975
+ const result = await masonCompleteInit(dir, { confluenceConfigured });
3976
+ return { content: [{ type: "text", text: result }] };
3977
+ }
3978
+ );
3979
+ server.tool(
3980
+ "mason_set_confluence",
3981
+ "Configure Confluence credentials. Two-step flow: (1) call without `spaceKey` to validate the credentials and receive a list of available spaces \u2014 relay them to the user. (2) call again with the same `baseUrl`/`email`/`apiToken` plus the chosen `spaceKey` to persist. Credentials are stored in `~/.mason/config.json`. Warn the user that the API token will be visible in chat history before they paste it.",
3982
+ {
3983
+ baseUrl: z.string().describe("Confluence base URL. Accepts `acme`, `acme.atlassian.net`, or `https://acme.atlassian.net` (normalized automatically)."),
3984
+ email: z.string().describe("User's Atlassian account email"),
3985
+ apiToken: z.string().describe("API token from id.atlassian.com/manage-profile/security/api-tokens"),
3986
+ spaceKey: z.string().optional().describe("Confluence space key. Omit on the first call to list available spaces."),
3987
+ parentPageId: z.string().optional().describe("Optional parent page ID under which Mason's index page is created")
3988
+ },
3989
+ async ({ baseUrl, email, apiToken, spaceKey, parentPageId }) => {
3990
+ const result = await masonSetConfluence({
3991
+ baseUrl,
3992
+ email,
3993
+ apiToken,
3994
+ spaceKey,
3995
+ parentPageId
3996
+ });
3997
+ return { content: [{ type: "text", text: result }] };
3998
+ }
3999
+ );
4000
+ server.tool(
4001
+ "full_analysis",
4002
+ "One-shot orientation for a project WITHOUT a concept map (get_snapshot returned exists:false). Returns git history stats, project structure with file counts, curated code sample previews (~60 lines each), and test-to-source mapping. On a mapped project, prefer get_snapshot \u2014 it is cheaper and answers feature/architecture questions directly.",
4003
+ {
4004
+ dir: z.string().describe("Absolute path to the project root directory")
4005
+ },
4006
+ async ({ dir }) => {
4007
+ const result = await fullAnalysis(dir);
4008
+ return {
4009
+ content: [{ type: "text", text: result }]
4010
+ };
4011
+ }
4012
+ );
4013
+ server.tool(
4014
+ "analyze_project",
4015
+ "Run git history analysis on a codebase. Returns commit convention patterns, stale directories, and frequently changed files. These are aggregate stats across hundreds of commits that would be expensive to compute manually.",
4016
+ {
4017
+ dir: z.string().describe("Absolute path to the project root directory")
4018
+ },
4019
+ async ({ dir }) => {
4020
+ const result = await analyzeProject(dir);
4021
+ return {
4022
+ content: [{ type: "text", text: result }]
4023
+ };
4024
+ }
4025
+ );
4026
+ server.tool(
4027
+ "get_code_samples",
4028
+ "Get previews (first ~60 lines) of representative source files from the codebase. Includes entry points, config files, hot files (frequently changed), test examples, and one file per directory for breadth. Read files natively for full content.",
4029
+ {
4030
+ dir: z.string().describe("Absolute path to the project root directory"),
4031
+ count: z.number().optional().default(15).describe("Maximum number of files to sample (default: 15)")
4032
+ },
4033
+ async ({ dir, count }) => {
4034
+ const result = await getCodeSamples(dir, count);
4035
+ return {
4036
+ content: [{ type: "text", text: result }]
4037
+ };
4038
+ }
4039
+ );
4040
+ server.tool(
4041
+ "get_snapshot",
4042
+ "CALL THIS FIRST \u2014 before grep, glob, or reading files \u2014 for any question about what this codebase does, its features, architecture, data flows, or where something is implemented ('where is X handled?', 'how does Y work?', 'what implements Z?'). Returns the persistent feature-to-file concept map in one cheap, instant, LLM-free call, replacing 5-10 exploration round-trips. Example: 'home screen' \u2192 [HomeScreen.kt, HomeViewModel.kt, HomeModule.kt]. Then read only the mapped files. If the map has drifted it says so (with a diff) \u2014 trust the freshness signal. If exists:false, the project isn't set up; offer mason_init.",
4043
+ {
4044
+ dir: z.string().describe("Absolute path to the project root directory")
4045
+ },
4046
+ async ({ dir }) => {
4047
+ const result = await getSnapshot(dir);
4048
+ return {
4049
+ content: [{ type: "text", text: result }]
4050
+ };
4051
+ }
4052
+ );
4053
+ server.tool(
4054
+ "get_context",
4055
+ "CALL THIS FIRST when given a task to implement, a bug to fix, a ticket, or a change request ('add X', 'fix Y', 'refactor Z'). One call returns everything needed to start: the matching features/flows with their files, related tests, blast radius for the key files (git co-change + references), and per-entry freshness \u2014 replacing a get_snapshot + get_impact + test-hunting sequence. Cheap, instant, LLM-free. Pass the task in natural language; optionally pass `files` (e.g. from a diff) to anchor the match. For open-ended architecture questions with no task, use get_snapshot instead.",
4056
+ {
4057
+ dir: z.string().describe("Absolute path to the project root directory"),
4058
+ task: z.string().describe("The task, bug, or change request in natural language \u2014 e.g. 'add rate limiting to the API client' or a ticket description"),
4059
+ files: z.array(z.string()).optional().describe("Optional file paths already known to be involved (e.g. from a diff or stack trace). Entries containing them are boosted above pure text matches.")
4060
+ },
4061
+ async ({ dir, task, files }) => {
4062
+ const result = await getContext(dir, task, files);
4063
+ return {
4064
+ content: [{ type: "text", text: result }]
4065
+ };
4066
+ }
4067
+ );
4068
+ server.tool(
4069
+ "generate_snapshot_batch",
4070
+ "Map step of the concept-map build. Returns one batch of source files (skeletons of every file in the batch plus a few deeper-read bodies for grounding), along with a system prompt instructing you to derive features and flows for ONLY this batch. Call repeatedly with the returned `nextOffset` until it is null, calling `save_partial_snapshot` between each call. Use product-natural feature names so partials merge cleanly in the reduce step.",
4071
+ {
4072
+ dir: z.string().describe("Absolute path to the project root directory"),
4073
+ offset: z.number().int().optional().describe("0-indexed file offset to start the batch at. Omit on the first call; pass the `nextOffset` from the previous response for subsequent calls."),
4074
+ batchSize: z.number().int().optional().describe("Files per batch. Defaults to 50."),
4075
+ files: z.array(z.string()).optional().describe("Scope the batch walk to this explicit file list \u2014 e.g. the drift set from mason_check_drift (changedFiles + unmappedFiles). Pass the SAME list on every batch call of one refresh run. Triggers refresh mode: reduce_snapshot will merge the partials into the existing map instead of rebuilding it.")
4076
+ },
4077
+ async ({ dir, offset, batchSize, files }) => {
4078
+ const result = await generateSnapshotBatch(dir, offset, batchSize, files);
4079
+ return {
4080
+ content: [{ type: "text", text: result }]
4081
+ };
4082
+ }
4083
+ );
4084
+ server.tool(
4085
+ "save_partial_snapshot",
4086
+ "Persist the partial concept map you derived for one batch. Call this once per batch, with the `batchId` from the `generate_snapshot_batch` response. Partials accumulate in `.mason/partial-snapshots/` and are merged in the reduce step.",
4087
+ {
4088
+ dir: z.string().describe("Absolute path to the project root directory"),
4089
+ batchId: z.string().describe("The `batchId` returned by `generate_snapshot_batch`."),
4090
+ offset: z.number().int().describe("The `offset` returned by `generate_snapshot_batch`. Used to order partials in the reduce step."),
4091
+ features: z.record(
4092
+ z.object({
4093
+ description: z.string(),
4094
+ files: z.array(z.string()),
4095
+ tests: z.array(z.string()).optional(),
4096
+ type: z.enum(["capability", "infrastructure"]).optional().describe(
4097
+ '"capability" (user-facing functionality) or "infrastructure" (internal plumbing with no end user \u2014 DI/service wiring, config, logging, adapters). Defaults to "capability".'
4098
+ )
4099
+ })
4100
+ ).describe("Partial features for this batch only \u2014 files outside the batch will be added by other partials."),
4101
+ flows: z.record(
4102
+ z.object({
4103
+ description: z.string(),
4104
+ chain: z.array(z.string())
4105
+ })
4106
+ ).describe("Partial flows whose entire chain is in this batch. Cross-batch flows are reconstructed in reduce.")
4107
+ },
4108
+ async ({ dir, batchId, offset, features, flows }) => {
4109
+ const result = await saveSnapshotPartial(dir, batchId, offset, features, flows);
4110
+ return {
4111
+ content: [{ type: "text", text: result }]
4112
+ };
4113
+ }
4114
+ );
4115
+ server.tool(
4116
+ "reduce_snapshot",
4117
+ "Reduce step of the concept-map build. Returns every partial snapshot plus a system prompt asking you to merge them into one coherent project-wide map. Resolve platform variants into single product features, dedupe near-duplicates, and ensure no file is dropped. After producing the unified map, call `save_snapshot` to persist it (this also clears the partials).",
4118
+ {
4119
+ dir: z.string().describe("Absolute path to the project root directory")
4120
+ },
4121
+ async ({ dir }) => {
4122
+ const result = await reduceSnapshot(dir);
4123
+ return {
4124
+ content: [{ type: "text", text: result }]
4125
+ };
4126
+ }
4127
+ );
4128
+ server.tool(
4129
+ "save_snapshot",
4130
+ "Save a concept-to-files map as a persistent project snapshot. Maps feature names and data flows to the files that implement them. Persists across conversations \u2014 future sessions can call get_snapshot to instantly find relevant files. No API key needed \u2014 you are the LLM generating the map.",
4131
+ {
4132
+ dir: z.string().describe("Absolute path to the project root directory"),
4133
+ features: z.record(
4134
+ z.object({
4135
+ description: z.string().describe("One-line description of the feature"),
4136
+ files: z.array(z.string()).describe("File paths that implement this feature"),
4137
+ tests: z.array(z.string()).optional().describe("Test file paths for this feature"),
4138
+ type: z.enum(["capability", "infrastructure"]).optional().describe(
4139
+ 'Classification: "capability" for user-facing functionality, "infrastructure" for internal plumbing with no end user (DI/service wiring, config, logging, adapters). Capabilities are published to Confluence; infrastructure stays in the AI concept map only. Defaults to "capability".'
4140
+ )
4141
+ })
4142
+ ).describe("Map of feature names to their implementing files"),
4143
+ flows: z.record(
4144
+ z.object({
4145
+ description: z.string().describe("One-line description of the flow"),
4146
+ chain: z.array(z.string()).describe("Ordered list of file paths showing data/call flow")
4147
+ })
4148
+ ).describe("Map of flow names to ordered file chains"),
4149
+ removeFeatures: z.array(z.string()).optional().describe("Feature names to delete from the existing map \u2014 for features that were renamed or no longer exist. Applied before merging; only meaningful on incremental saves."),
4150
+ removeFlows: z.array(z.string()).optional().describe("Flow names to delete from the existing map. Applied before merging; only meaningful on incremental saves.")
4151
+ },
4152
+ async ({ dir, features, flows, removeFeatures, removeFlows }) => {
4153
+ const result = await saveSnapshotData(
4154
+ dir,
4155
+ features,
4156
+ flows,
4157
+ removeFeatures ?? [],
4158
+ removeFlows ?? []
4159
+ );
4160
+ return {
4161
+ content: [{ type: "text", text: result }]
4162
+ };
4163
+ }
4164
+ );
4165
+ server.tool(
4166
+ "save_decision",
4167
+ "CALL THIS when you learn something about this codebase that the code alone can't tell you: a failed approach ('we tried X, it broke Y'), a deprecation ('don't extend Z'), a workaround and its reason, or a convention settled in review. Best moments: the end of a debugging session, right after a design choice. Records are git-committed to .mason/decisions/ and PR-reviewed like code; get_context surfaces them on matching tasks. Do NOT record anything derivable by reading the code, session trivia, or secrets. Also handles updates (pass id), re-verification (same id + content re-pins to HEAD), and supersession (pass supersedes).",
4168
+ {
4169
+ dir: z.string().describe("Absolute path to the project root directory"),
4170
+ title: z.string().max(80).describe("Short, specific headline \u2014 becomes the stable record id"),
4171
+ body: z.string().max(1500).describe("The knowledge itself: what was tried/decided, why, and what to avoid. Must contain information NOT derivable by reading the code."),
4172
+ category: z.enum(["decision", "gotcha", "deprecation", "convention"]),
4173
+ files: z.array(z.string()).optional().describe("Repo-relative files this applies to. Anchors drift-checking: if these change, the decision is flagged for re-verification."),
4174
+ id: z.string().optional().describe("Existing decision id to update. Passing id with unchanged content re-verifies it (re-pins refreshedHash to HEAD)."),
4175
+ supersedes: z.string().optional().describe("Id of a decision this one replaces \u2014 the old record is kept but marked superseded"),
4176
+ force: z.boolean().optional().describe("Save even when a near-duplicate was detected")
4177
+ },
4178
+ async ({ dir, title, body, category, files, id, supersedes, force }) => {
4179
+ const result = await saveDecision(dir, {
4180
+ title,
4181
+ body,
4182
+ category,
4183
+ files,
4184
+ id,
4185
+ supersedes,
4186
+ force
4187
+ });
4188
+ return { content: [{ type: "text", text: result }] };
4189
+ }
4190
+ );
4191
+ server.tool(
4192
+ "mason_check_drift",
4193
+ "Check how far the concept map has drifted from HEAD. Deterministic (git + filesystem, no LLM). Returns which features/flows are stale and the changed files behind them, new source files not yet mapped, ghost files (mapped but deleted), renames, and a `recommendation`: `up-to-date` (nothing to do), `incremental` (update just the stale entries via save_snapshot), or `full-rebuild` (re-run the Map-Reduce build). Call this before trusting the map in a long session, or periodically to keep the map and any synced wikis fresh.",
4194
+ {
4195
+ dir: z.string().describe("Absolute path to the project root directory")
4196
+ },
4197
+ async ({ dir }) => {
4198
+ const result = await checkDrift(dir);
4199
+ return {
4200
+ content: [{ type: "text", text: result }]
4201
+ };
4202
+ }
4203
+ );
4204
+ server.tool(
4205
+ "verify_snapshot",
4206
+ "Spot-check the concept map's CORRECTNESS (drift checks freshness; this checks entries were right to begin with). Returns a sample of entries \u2014 always the never-verified and least-recently-verified first \u2014 with skeletons of their claimed files, for you to judge whether the files actually implement what the entry claims. Report verdicts back via save_verification. Run periodically, or after an automated refresh wrote entries no human reviewed.",
4207
+ {
4208
+ dir: z.string().describe("Absolute path to the project root directory"),
4209
+ sample: z.number().int().optional().describe("Entries to sample (default 5)")
4210
+ },
4211
+ async ({ dir, sample }) => {
4212
+ const result = await verifySnapshot(dir, sample);
4213
+ return { content: [{ type: "text", text: result }] };
4214
+ }
4215
+ );
4216
+ server.tool(
4217
+ "save_verification",
4218
+ "Record verify_snapshot verdicts. Entries judged ok are stamped verifiedAt; failures are flagged verificationFailed with your note and surface in mason_check_drift until re-mapped. Verdict notes are required for failures.",
4219
+ {
4220
+ dir: z.string().describe("Absolute path to the project root directory"),
4221
+ verdicts: z.record(
4222
+ z.object({
4223
+ ok: z.boolean(),
4224
+ note: z.string().optional().describe("One line on what's wrong \u2014 required when ok is false")
4225
+ })
4226
+ ).describe("Entry name \u2192 verdict, exactly as returned by verify_snapshot")
4227
+ },
4228
+ async ({ dir, verdicts }) => {
4229
+ const result = await saveVerification(dir, verdicts);
4230
+ return { content: [{ type: "text", text: result }] };
4231
+ }
4232
+ );
4233
+ server.tool(
4234
+ "get_impact",
4235
+ "CALL THIS BEFORE editing, refactoring, or assessing the blast radius of any file. Returns three signals you cannot get by reading the file itself: git co-change history (files that historically change in the same commits), references (files that mention the target by name), and related tests. One call replaces a manual sweep of grep + git log. Also the right tool for 'what would break if I changed X?' questions.",
4236
+ {
4237
+ dir: z.string().describe("Absolute path to the project root directory"),
4238
+ files: z.array(z.string()).describe("File paths or names to analyze (e.g., ['WeatherRepository.kt'] or ['src/services/auth.ts'])")
4239
+ },
4240
+ async ({ dir, files }) => {
4241
+ const result = await getImpact(dir, files);
4242
+ return {
4243
+ content: [{ type: "text", text: result }]
4244
+ };
4245
+ }
4246
+ );
4247
+ server.tool(
4248
+ "export_to_confluence",
4249
+ "Sync the project's concept map to Confluence as product-readable wiki pages: an index page, one page per feature (PM-language descriptions, no file paths), and a changelog page. Hand-edits outside `<!-- mason:start/end:* -->` markers are preserved across syncs. Requires `mason_set_confluence` to have been called first.",
4250
+ {
4251
+ dir: z.string().describe("Absolute path to the project root directory"),
4252
+ spaceKey: z.string().optional().describe("Override the configured space key"),
4253
+ parentPageId: z.string().optional().describe("Override the configured parent page ID"),
4254
+ indexPageTitle: z.string().optional().describe("Title of the index page (default: 'Mason \u2014 System Map')"),
4255
+ changelogPageTitle: z.string().optional().describe("Title of the changelog page (default: 'Mason \u2014 Changelog')"),
4256
+ featurePagePrefix: z.string().optional().describe("Prefix for each feature page title (default: 'Feature: ')")
4257
+ },
4258
+ async ({ dir, spaceKey, parentPageId, indexPageTitle, changelogPageTitle, featurePagePrefix }) => {
4259
+ const result = await exportToConfluenceTool(dir, {
4260
+ spaceKey,
4261
+ parentPageId,
4262
+ indexPageTitle,
4263
+ changelogPageTitle,
4264
+ featurePagePrefix
4265
+ });
4266
+ return { content: [{ type: "text", text: result }] };
4267
+ }
4268
+ );
4269
+ return server;
4270
+ }
4271
+ async function startMcpServer() {
4272
+ const server = createMcpServer();
4273
+ const transport = new StdioServerTransport();
4274
+ await server.connect(transport);
4275
+ }
4276
+
4277
+ // bin/mason-mcp.ts
4278
+ startMcpServer().catch((err) => {
4279
+ process.stderr.write(`Mason MCP server error: ${err}
4280
+ `);
4281
+ process.exit(1);
4282
+ });
4283
+ //# sourceMappingURL=mason-mcp.js.map