switchman-dev 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/git.js CHANGED
@@ -3,9 +3,18 @@
3
3
  * Worktree discovery and conflict detection via git merge-tree
4
4
  */
5
5
 
6
- import { execSync, spawnSync } from 'child_process';
7
- import { existsSync } from 'fs';
6
+ import { execFileSync, execSync, spawnSync } from 'child_process';
7
+ import { existsSync, realpathSync } from 'fs';
8
8
  import { join, relative, resolve, basename } from 'path';
9
+ import { filterIgnoredPaths } from './ignore.js';
10
+
11
+ function normalizeFsPath(path) {
12
+ try {
13
+ return realpathSync(path);
14
+ } catch {
15
+ return resolve(path);
16
+ }
17
+ }
9
18
 
10
19
  /**
11
20
  * Find the switchman database root from cwd or a given path.
@@ -55,12 +64,26 @@ export function findRepoRoot(startPath = process.cwd()) {
55
64
  }
56
65
  }
57
66
 
67
+ export function getGitCommonDir(startPath = process.cwd()) {
68
+ try {
69
+ const commonDir = execSync('git rev-parse --git-common-dir', {
70
+ cwd: startPath,
71
+ encoding: 'utf8',
72
+ stdio: ['pipe', 'pipe', 'pipe'],
73
+ }).trim();
74
+ return resolve(startPath, commonDir);
75
+ } catch {
76
+ throw new Error('Not inside a git repository. Run switchman from inside a git repo.');
77
+ }
78
+ }
79
+
58
80
  /**
59
81
  * List all git worktrees for this repo
60
82
  * Returns: [{ name, path, branch, isMain, HEAD }]
61
83
  */
62
84
  export function listGitWorktrees(repoRoot) {
63
85
  try {
86
+ const normalizedRepoRoot = normalizeFsPath(repoRoot);
64
87
  const output = execSync('git worktree list --porcelain', {
65
88
  cwd: repoRoot,
66
89
  encoding: 'utf8',
@@ -83,8 +106,9 @@ export function listGitWorktrees(repoRoot) {
83
106
  }
84
107
 
85
108
  if (wt.path) {
86
- wt.name = wt.path === repoRoot ? 'main' : wt.path.split('/').pop();
87
- wt.isMain = wt.path === repoRoot;
109
+ const normalizedPath = normalizeFsPath(wt.path);
110
+ wt.name = normalizedPath === normalizedRepoRoot ? 'main' : wt.path.split('/').pop();
111
+ wt.isMain = normalizedPath === normalizedRepoRoot;
88
112
  worktrees.push(wt);
89
113
  }
90
114
  }
@@ -95,6 +119,12 @@ export function listGitWorktrees(repoRoot) {
95
119
  }
96
120
  }
97
121
 
122
+ export function getCurrentWorktree(repoRoot, startPath = process.cwd()) {
123
+ const currentPath = normalizeFsPath(startPath);
124
+ const worktrees = listGitWorktrees(repoRoot);
125
+ return worktrees.find((wt) => normalizeFsPath(wt.path) === currentPath) || null;
126
+ }
127
+
98
128
  /**
99
129
  * Get files changed in a worktree relative to its base branch
100
130
  * Returns array of file paths (relative to repo root)
@@ -126,7 +156,7 @@ export function getWorktreeChangedFiles(worktreePath, repoRoot) {
126
156
  ...untracked.split('\n'),
127
157
  ].filter(Boolean);
128
158
 
129
- return [...new Set(allFiles)];
159
+ return filterIgnoredPaths([...new Set(allFiles)]);
130
160
  } catch {
131
161
  return [];
132
162
  }
@@ -236,6 +266,79 @@ export function getWorktreeBranch(worktreePath) {
236
266
  }
237
267
  }
238
268
 
269
+ export function gitBranchExists(repoRoot, branch) {
270
+ const result = spawnSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], {
271
+ cwd: repoRoot,
272
+ encoding: 'utf8',
273
+ });
274
+ return result.status === 0;
275
+ }
276
+
277
+ export function gitRevParse(repoRoot, ref) {
278
+ try {
279
+ return execFileSync('git', ['rev-parse', ref], {
280
+ cwd: repoRoot,
281
+ encoding: 'utf8',
282
+ stdio: ['pipe', 'pipe', 'pipe'],
283
+ }).trim();
284
+ } catch {
285
+ return null;
286
+ }
287
+ }
288
+
289
+ export function gitGetCurrentBranch(repoRoot) {
290
+ try {
291
+ return execFileSync('git', ['branch', '--show-current'], {
292
+ cwd: repoRoot,
293
+ encoding: 'utf8',
294
+ stdio: ['pipe', 'pipe', 'pipe'],
295
+ }).trim() || null;
296
+ } catch {
297
+ return null;
298
+ }
299
+ }
300
+
301
+ export function gitCheckout(repoRoot, ref) {
302
+ execFileSync('git', ['checkout', ref], {
303
+ cwd: repoRoot,
304
+ encoding: 'utf8',
305
+ stdio: ['ignore', 'pipe', 'pipe'],
306
+ });
307
+ }
308
+
309
+ export function gitRebaseOnto(repoRoot, baseBranch, topicBranch) {
310
+ const previousBranch = gitGetCurrentBranch(repoRoot);
311
+ try {
312
+ gitCheckout(repoRoot, topicBranch);
313
+ execFileSync('git', ['rebase', baseBranch], {
314
+ cwd: repoRoot,
315
+ encoding: 'utf8',
316
+ stdio: ['ignore', 'pipe', 'pipe'],
317
+ });
318
+ } finally {
319
+ if (previousBranch && previousBranch !== topicBranch) {
320
+ try { gitCheckout(repoRoot, previousBranch); } catch { /* no-op */ }
321
+ }
322
+ }
323
+ }
324
+
325
+ export function gitMergeBranchInto(repoRoot, baseBranch, topicBranch) {
326
+ const previousBranch = gitGetCurrentBranch(repoRoot);
327
+ try {
328
+ gitCheckout(repoRoot, baseBranch);
329
+ execFileSync('git', ['merge', '--ff-only', topicBranch], {
330
+ cwd: repoRoot,
331
+ encoding: 'utf8',
332
+ stdio: ['ignore', 'pipe', 'pipe'],
333
+ });
334
+ return gitRevParse(repoRoot, 'HEAD');
335
+ } finally {
336
+ if (previousBranch && previousBranch !== baseBranch) {
337
+ try { gitCheckout(repoRoot, previousBranch); } catch { /* no-op */ }
338
+ }
339
+ }
340
+ }
341
+
239
342
  /**
240
343
  * Create a new git worktree
241
344
  */
@@ -0,0 +1,49 @@
1
+ export const DEFAULT_SCAN_IGNORE_PATTERNS = [
2
+ 'node_modules/**',
3
+ '.git/**',
4
+ '.mcp.json',
5
+ '.cursor/mcp.json',
6
+ '.switchman/**',
7
+ 'dist/**',
8
+ 'build/**',
9
+ 'coverage/**',
10
+ '.next/**',
11
+ '.nuxt/**',
12
+ '.svelte-kit/**',
13
+ '.turbo/**',
14
+ '.cache/**',
15
+ '.parcel-cache/**',
16
+ 'target/**',
17
+ 'out/**',
18
+ 'tmp/**',
19
+ 'temp/**',
20
+ ];
21
+
22
+ function normalizePath(filePath) {
23
+ return String(filePath || '')
24
+ .replace(/\\/g, '/')
25
+ .replace(/^\.\/+/, '')
26
+ .replace(/^\/+/, '');
27
+ }
28
+
29
+ function patternPrefix(pattern) {
30
+ return normalizePath(pattern).replace(/\/\*\*$/, '');
31
+ }
32
+
33
+ export function matchesPathPatterns(filePath, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
34
+ const normalized = normalizePath(filePath);
35
+ if (!normalized) return false;
36
+
37
+ return patterns.some((pattern) => {
38
+ const prefix = patternPrefix(pattern);
39
+ return normalized === prefix || normalized.startsWith(`${prefix}/`) || normalized.includes(`/${prefix}/`);
40
+ });
41
+ }
42
+
43
+ export function isIgnoredPath(filePath, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
44
+ return matchesPathPatterns(filePath, patterns);
45
+ }
46
+
47
+ export function filterIgnoredPaths(filePaths, patterns = DEFAULT_SCAN_IGNORE_PATTERNS) {
48
+ return filePaths.filter((filePath) => !isIgnoredPath(filePath, patterns));
49
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ export function getSwitchmanMcpServers() {
6
+ return {
7
+ switchman: {
8
+ command: 'switchman-mcp',
9
+ args: [],
10
+ },
11
+ };
12
+ }
13
+
14
+ export function getSwitchmanMcpConfig() {
15
+ return {
16
+ mcpServers: getSwitchmanMcpServers(),
17
+ };
18
+ }
19
+
20
+ function upsertMcpConfigFile(configPath) {
21
+ let config = {};
22
+ let created = true;
23
+
24
+ if (existsSync(configPath)) {
25
+ created = false;
26
+ const raw = readFileSync(configPath, 'utf8').trim();
27
+ config = raw ? JSON.parse(raw) : {};
28
+ } else {
29
+ mkdirSync(join(configPath, '..'), { recursive: true });
30
+ }
31
+
32
+ const nextConfig = {
33
+ ...config,
34
+ mcpServers: {
35
+ ...(config.mcpServers || {}),
36
+ ...getSwitchmanMcpServers(),
37
+ },
38
+ };
39
+
40
+ const before = JSON.stringify(config);
41
+ const after = JSON.stringify(nextConfig);
42
+ const changed = before !== after;
43
+
44
+ if (changed) {
45
+ writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`);
46
+ }
47
+
48
+ return {
49
+ path: configPath,
50
+ created,
51
+ changed,
52
+ };
53
+ }
54
+
55
+ export function upsertCursorProjectMcpConfig(targetDir) {
56
+ return upsertMcpConfigFile(join(targetDir, '.cursor', 'mcp.json'));
57
+ }
58
+
59
+ export function upsertAllProjectMcpConfigs(targetDir) {
60
+ return [
61
+ upsertProjectMcpConfig(targetDir),
62
+ upsertCursorProjectMcpConfig(targetDir),
63
+ ];
64
+ }
65
+
66
+ export function upsertProjectMcpConfig(targetDir) {
67
+ return upsertMcpConfigFile(join(targetDir, '.mcp.json'));
68
+ }
69
+
70
+ export function getWindsurfMcpConfigPath(homeDir = homedir()) {
71
+ return join(homeDir, '.codeium', 'mcp_config.json');
72
+ }
73
+
74
+ export function upsertWindsurfMcpConfig(homeDir = homedir()) {
75
+ return upsertMcpConfigFile(getWindsurfMcpConfigPath(homeDir));
76
+ }
@@ -0,0 +1,305 @@
1
+ import { getTask, listBoundaryValidationStates, listDependencyInvalidations, logAuditEvent } from './db.js';
2
+ import { scanAllWorktrees } from './detector.js';
3
+
4
+ const RISK_PATTERNS = [
5
+ { key: 'auth', regex: /(^|\/)(auth|login|session|permissions?|rbac|acl)(\/|$)/i, label: 'authentication or permissions' },
6
+ { key: 'schema', regex: /(^|\/)(schema|migrations?|db|database|sql)(\/|$)|schema\./i, label: 'schema or database' },
7
+ { key: 'config', regex: /(^|\/)(config|configs|settings)(\/|$)|(^|\/)(package\.json|pnpm-lock\.yaml|package-lock\.json|yarn\.lock|tsconfig.*|vite\.config.*|webpack\.config.*|dockerfile|docker-compose.*)$/i, label: 'shared configuration' },
8
+ { key: 'api', regex: /(^|\/)(api|routes?|controllers?)(\/|$)/i, label: 'API surface' },
9
+ ];
10
+
11
+ function isTestPath(filePath) {
12
+ return /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(filePath);
13
+ }
14
+
15
+ function isSourcePath(filePath) {
16
+ return /(^|\/)(src|app|lib|server|client)(\/|$)/i.test(filePath) && !isTestPath(filePath);
17
+ }
18
+
19
+ function classifyRiskTags(filePath) {
20
+ return RISK_PATTERNS.filter((pattern) => pattern.regex.test(filePath)).map((pattern) => pattern.key);
21
+ }
22
+
23
+ function describeRiskTag(tag) {
24
+ return RISK_PATTERNS.find((pattern) => pattern.key === tag)?.label || tag;
25
+ }
26
+
27
+ function pathAreas(filePath) {
28
+ const parts = filePath.split('/').filter(Boolean);
29
+ if (parts.length === 0) return [];
30
+ if (parts.length === 1) return [parts[0]];
31
+ if (['src', 'app', 'lib', 'tests', 'test', 'spec'].includes(parts[0])) {
32
+ return [`${parts[0]}/${parts[1]}`];
33
+ }
34
+ return [parts[0]];
35
+ }
36
+
37
+ function intersection(a, b) {
38
+ const setB = new Set(b);
39
+ return [...new Set(a)].filter((item) => setB.has(item));
40
+ }
41
+
42
+ function summarizeWorktree(worktree, changedFiles, complianceState) {
43
+ const sourceFiles = changedFiles.filter(isSourcePath);
44
+ const testFiles = changedFiles.filter(isTestPath);
45
+ const riskTags = [...new Set(changedFiles.flatMap(classifyRiskTags))];
46
+ const areas = [...new Set(changedFiles.flatMap(pathAreas))];
47
+ const findings = [];
48
+ let score = 0;
49
+
50
+ if (sourceFiles.length > 0 && testFiles.length === 0) {
51
+ findings.push('source changes without corresponding test updates');
52
+ score += 15;
53
+ }
54
+
55
+ if (riskTags.length > 0) {
56
+ findings.push(`touches ${riskTags.map(describeRiskTag).join(', ')}`);
57
+ score += Math.min(25, riskTags.length * 10);
58
+ }
59
+
60
+ if (complianceState === 'non_compliant' || complianceState === 'stale') {
61
+ findings.push(`worktree is ${complianceState}`);
62
+ score += 60;
63
+ }
64
+
65
+ return {
66
+ worktree: worktree.name,
67
+ branch: worktree.branch ?? 'unknown',
68
+ changed_files: changedFiles,
69
+ source_files: sourceFiles,
70
+ test_files: testFiles,
71
+ risk_tags: riskTags,
72
+ areas,
73
+ findings,
74
+ score,
75
+ };
76
+ }
77
+
78
+ function buildPairAnalysis(left, right, report) {
79
+ const directFileConflict = report.fileConflicts.filter((conflict) =>
80
+ conflict.worktrees.includes(left.worktree) && conflict.worktrees.includes(right.worktree),
81
+ );
82
+ const branchConflict = report.conflicts.find((conflict) =>
83
+ (conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
84
+ || (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
85
+ ) || null;
86
+ const ownershipConflicts = (report.ownershipConflicts || []).filter((conflict) =>
87
+ (conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
88
+ || (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
89
+ );
90
+ const semanticConflicts = (report.semanticConflicts || []).filter((conflict) =>
91
+ (conflict.worktreeA === left.worktree && conflict.worktreeB === right.worktree)
92
+ || (conflict.worktreeA === right.worktree && conflict.worktreeB === left.worktree),
93
+ );
94
+ const sharedAreas = intersection(left.areas, right.areas);
95
+ const sharedRiskTags = intersection(left.risk_tags, right.risk_tags);
96
+
97
+ const reasons = [];
98
+ let score = 0;
99
+
100
+ if (branchConflict) {
101
+ reasons.push(`git merge conflict predicted between ${left.branch} and ${right.branch}`);
102
+ score = 100;
103
+ }
104
+
105
+ if (directFileConflict.length > 0) {
106
+ reasons.push(`direct file overlap in ${directFileConflict.map((item) => item.file).join(', ')}`);
107
+ score = Math.max(score, 95);
108
+ }
109
+
110
+ if (ownershipConflicts.length > 0) {
111
+ for (const conflict of ownershipConflicts) {
112
+ if (conflict.type === 'subsystem_overlap') {
113
+ reasons.push(`both worktrees reserve the ${conflict.subsystemTag} subsystem`);
114
+ score = Math.max(score, 85);
115
+ } else if (conflict.type === 'scope_overlap') {
116
+ reasons.push(`both worktrees reserve overlapping scopes (${conflict.scopeA} vs ${conflict.scopeB})`);
117
+ score = Math.max(score, 90);
118
+ }
119
+ }
120
+ }
121
+
122
+ if (semanticConflicts.length > 0) {
123
+ for (const conflict of semanticConflicts) {
124
+ if (conflict.type === 'semantic_object_overlap') {
125
+ reasons.push(`both worktrees changed exported ${conflict.object_kind} ${conflict.object_name}`);
126
+ score = Math.max(score, 92);
127
+ } else if (conflict.type === 'semantic_name_overlap') {
128
+ reasons.push(`both worktrees changed semantically similar object ${conflict.object_name}`);
129
+ score = Math.max(score, 65);
130
+ }
131
+ }
132
+ }
133
+
134
+ if (sharedAreas.length > 0) {
135
+ reasons.push(`both worktrees touch ${sharedAreas.join(', ')}`);
136
+ score += 35;
137
+ }
138
+
139
+ if (sharedRiskTags.length > 0) {
140
+ reasons.push(`both worktrees change ${sharedRiskTags.map(describeRiskTag).join(', ')}`);
141
+ score += 25;
142
+ }
143
+
144
+ if (left.source_files.length > 0 && left.test_files.length === 0 && sharedAreas.length > 0) {
145
+ reasons.push(`${left.worktree} changes shared source areas without tests`);
146
+ score += 10;
147
+ }
148
+
149
+ if (right.source_files.length > 0 && right.test_files.length === 0 && sharedAreas.length > 0) {
150
+ reasons.push(`${right.worktree} changes shared source areas without tests`);
151
+ score += 10;
152
+ }
153
+
154
+ if (left.changed_files.length >= 5 && right.changed_files.length >= 5) {
155
+ reasons.push('both worktrees are large enough to raise integration risk');
156
+ score += 10;
157
+ }
158
+
159
+ const status = score >= 80 ? 'blocked' : score >= 40 ? 'warn' : 'pass';
160
+
161
+ return {
162
+ worktree_a: left.worktree,
163
+ worktree_b: right.worktree,
164
+ branch_a: left.branch,
165
+ branch_b: right.branch,
166
+ status,
167
+ score: Math.min(score, 100),
168
+ reasons,
169
+ shared_areas: sharedAreas,
170
+ shared_risk_tags: sharedRiskTags,
171
+ ownership_conflicts: ownershipConflicts,
172
+ semantic_conflicts: semanticConflicts,
173
+ conflicting_files: branchConflict?.conflictingFiles || directFileConflict.map((item) => item.file),
174
+ };
175
+ }
176
+
177
+ function summarizeOverall(pairAnalyses, worktreeAnalyses, boundaryValidations, dependencyInvalidations) {
178
+ const blockedPairs = pairAnalyses.filter((item) => item.status === 'blocked');
179
+ const warnedPairs = pairAnalyses.filter((item) => item.status === 'warn');
180
+ const riskyWorktrees = worktreeAnalyses.filter((item) => item.score >= 40);
181
+ const blockedValidations = boundaryValidations.filter((item) => item.severity === 'blocked');
182
+ const warnedValidations = boundaryValidations.filter((item) => item.severity === 'warn');
183
+ const blockedInvalidations = dependencyInvalidations.filter((item) => item.severity === 'blocked');
184
+ const warnedInvalidations = dependencyInvalidations.filter((item) => item.severity === 'warn');
185
+
186
+ if (blockedPairs.length > 0 || blockedValidations.length > 0 || blockedInvalidations.length > 0) {
187
+ return {
188
+ status: 'blocked',
189
+ summary: `AI merge gate blocked: ${blockedPairs.length} risky pair(s), ${blockedValidations.length} boundary validation issue(s), and ${blockedInvalidations.length} stale dependency issue(s) need resolution.`,
190
+ };
191
+ }
192
+ if (warnedPairs.length > 0 || riskyWorktrees.length > 0 || warnedValidations.length > 0 || warnedInvalidations.length > 0) {
193
+ return {
194
+ status: 'warn',
195
+ summary: `AI merge gate warns: ${warnedPairs.length} pair(s), ${riskyWorktrees.length} worktree(s), ${warnedValidations.length} boundary validation issue(s), or ${warnedInvalidations.length} stale dependency issue(s) need review.`,
196
+ };
197
+ }
198
+ return {
199
+ status: 'pass',
200
+ summary: 'AI merge gate passed: no elevated semantic merge risks detected.',
201
+ };
202
+ }
203
+
204
+ function evaluateBoundaryValidations(db) {
205
+ return listBoundaryValidationStates(db)
206
+ .filter((state) => state.status === 'blocked' || state.status === 'pending_validation')
207
+ .map((state) => {
208
+ const task = getTask(db, state.task_id);
209
+ return {
210
+ pipeline_id: state.pipeline_id,
211
+ task_id: state.task_id,
212
+ worktree: task?.worktree || null,
213
+ severity: state.status === 'blocked' ? 'blocked' : 'warn',
214
+ missing_task_types: state.missing_task_types || [],
215
+ rationale: state.details?.rationale || [],
216
+ subsystem_tags: state.details?.subsystem_tags || [],
217
+ summary: `${task?.title || state.task_id} is missing completed ${(state.missing_task_types || []).join(', ')} validation work`,
218
+ touched_at: state.touched_at,
219
+ validation_status: state.status,
220
+ };
221
+ });
222
+ }
223
+
224
+ function evaluateDependencyInvalidations(db) {
225
+ return listDependencyInvalidations(db, { status: 'stale' })
226
+ .map((state) => {
227
+ const affectedTask = getTask(db, state.affected_task_id);
228
+ const severity = affectedTask?.status === 'done' ? 'blocked' : 'warn';
229
+ const staleArea = state.reason_type === 'subsystem_overlap'
230
+ ? `subsystem:${state.subsystem_tag}`
231
+ : `${state.source_scope_pattern} ↔ ${state.affected_scope_pattern}`;
232
+ return {
233
+ source_lease_id: state.source_lease_id,
234
+ source_task_id: state.source_task_id,
235
+ source_pipeline_id: state.source_pipeline_id,
236
+ source_worktree: state.source_worktree,
237
+ affected_task_id: state.affected_task_id,
238
+ affected_pipeline_id: state.affected_pipeline_id,
239
+ affected_worktree: state.affected_worktree,
240
+ affected_task_status: affectedTask?.status || null,
241
+ severity,
242
+ reason_type: state.reason_type,
243
+ subsystem_tag: state.subsystem_tag,
244
+ source_scope_pattern: state.source_scope_pattern,
245
+ affected_scope_pattern: state.affected_scope_pattern,
246
+ summary: `${affectedTask?.title || state.affected_task_id} is stale because ${state.details?.source_task_title || state.source_task_id} changed shared ${staleArea}`,
247
+ stale_area: staleArea,
248
+ created_at: state.created_at,
249
+ };
250
+ });
251
+ }
252
+
253
+ export async function runAiMergeGate(db, repoRoot) {
254
+ const report = await scanAllWorktrees(db, repoRoot);
255
+ const worktreeAnalyses = report.worktrees.map((worktree) => {
256
+ const changedFiles = report.fileMap?.[worktree.name] ?? [];
257
+ const complianceState = report.worktreeCompliance?.find((entry) => entry.worktree === worktree.name)?.compliance_state
258
+ ?? worktree.compliance_state
259
+ ?? 'observed';
260
+ return summarizeWorktree(worktree, changedFiles, complianceState);
261
+ });
262
+
263
+ const pairAnalyses = [];
264
+ for (let i = 0; i < worktreeAnalyses.length; i++) {
265
+ for (let j = i + 1; j < worktreeAnalyses.length; j++) {
266
+ pairAnalyses.push(buildPairAnalysis(worktreeAnalyses[i], worktreeAnalyses[j], report));
267
+ }
268
+ }
269
+
270
+ const boundaryValidations = evaluateBoundaryValidations(db);
271
+ const dependencyInvalidations = evaluateDependencyInvalidations(db);
272
+ const overall = summarizeOverall(pairAnalyses, worktreeAnalyses, boundaryValidations, dependencyInvalidations);
273
+ const result = {
274
+ ok: overall.status === 'pass',
275
+ status: overall.status,
276
+ summary: overall.summary,
277
+ worktrees: worktreeAnalyses,
278
+ pairs: pairAnalyses,
279
+ boundary_validations: boundaryValidations,
280
+ dependency_invalidations: dependencyInvalidations,
281
+ compliance: report.complianceSummary,
282
+ unclaimed_changes: report.unclaimedChanges,
283
+ branch_conflicts: report.conflicts,
284
+ file_conflicts: report.fileConflicts,
285
+ ownership_conflicts: report.ownershipConflicts || [],
286
+ semantic_conflicts: report.semanticConflicts || [],
287
+ };
288
+
289
+ logAuditEvent(db, {
290
+ eventType: 'ai_merge_gate',
291
+ status: overall.status === 'blocked' ? 'denied' : (overall.status === 'warn' ? 'warn' : 'allowed'),
292
+ reasonCode: overall.status === 'blocked' ? 'semantic_merge_risk' : null,
293
+ details: JSON.stringify({
294
+ status: result.status,
295
+ blocked_pairs: pairAnalyses.filter((item) => item.status === 'blocked').length,
296
+ warned_pairs: pairAnalyses.filter((item) => item.status === 'warn').length,
297
+ blocked_boundary_validations: boundaryValidations.filter((item) => item.severity === 'blocked').length,
298
+ warned_boundary_validations: boundaryValidations.filter((item) => item.severity === 'warn').length,
299
+ blocked_dependency_invalidations: dependencyInvalidations.filter((item) => item.severity === 'blocked').length,
300
+ warned_dependency_invalidations: dependencyInvalidations.filter((item) => item.severity === 'warn').length,
301
+ }),
302
+ });
303
+
304
+ return result;
305
+ }
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+
4
+ export function getMonitorStatePath(repoRoot) {
5
+ return join(repoRoot, '.switchman', 'monitor.json');
6
+ }
7
+
8
+ export function readMonitorState(repoRoot) {
9
+ const statePath = getMonitorStatePath(repoRoot);
10
+ if (!existsSync(statePath)) return null;
11
+
12
+ try {
13
+ return JSON.parse(readFileSync(statePath, 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function writeMonitorState(repoRoot, state) {
20
+ const statePath = getMonitorStatePath(repoRoot);
21
+ mkdirSync(dirname(statePath), { recursive: true });
22
+ writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`);
23
+ return statePath;
24
+ }
25
+
26
+ export function clearMonitorState(repoRoot) {
27
+ const statePath = getMonitorStatePath(repoRoot);
28
+ rmSync(statePath, { force: true });
29
+ }
30
+
31
+ export function isProcessRunning(pid) {
32
+ if (!Number.isInteger(pid) || pid <= 0) return false;
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }