switchman-dev 0.1.1 → 0.1.3

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,966 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
2
+ import { dirname, join, resolve, relative } from 'path';
3
+ import { execFileSync, spawnSync } from 'child_process';
4
+
5
+ import {
6
+ getActiveFileClaims,
7
+ getCompletedFileClaims,
8
+ getLease,
9
+ getTaskSpec,
10
+ getWorktree,
11
+ getWorktreeSnapshotState,
12
+ replaceWorktreeSnapshotState,
13
+ getStaleLeases,
14
+ listAuditEvents,
15
+ listLeases,
16
+ logAuditEvent,
17
+ updateWorktreeCompliance,
18
+ } from './db.js';
19
+ import { isIgnoredPath, matchesPathPatterns } from './ignore.js';
20
+ import { getCurrentWorktree, getGitCommonDir, getWorktreeChangedFiles } from './git.js';
21
+
22
+ export const COMPLIANCE_STATES = {
23
+ MANAGED: 'managed',
24
+ OBSERVED: 'observed',
25
+ NON_COMPLIANT: 'non_compliant',
26
+ STALE: 'stale',
27
+ };
28
+
29
+ const DEFAULT_ENFORCEMENT_POLICY = {
30
+ allowed_generated_paths: [],
31
+ };
32
+
33
+ function getEnforcementPolicyPath(repoRoot) {
34
+ return join(repoRoot, '.switchman', 'enforcement.json');
35
+ }
36
+
37
+ export function loadEnforcementPolicy(repoRoot) {
38
+ const policyPath = getEnforcementPolicyPath(repoRoot);
39
+ if (!existsSync(policyPath)) {
40
+ return DEFAULT_ENFORCEMENT_POLICY;
41
+ }
42
+
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(policyPath, 'utf8'));
45
+ return {
46
+ ...DEFAULT_ENFORCEMENT_POLICY,
47
+ ...parsed,
48
+ allowed_generated_paths: Array.isArray(parsed.allowed_generated_paths) ? parsed.allowed_generated_paths : [],
49
+ };
50
+ } catch {
51
+ return DEFAULT_ENFORCEMENT_POLICY;
52
+ }
53
+ }
54
+
55
+ export function writeEnforcementPolicy(repoRoot, policy) {
56
+ const policyPath = getEnforcementPolicyPath(repoRoot);
57
+ mkdirSync(dirname(policyPath), { recursive: true });
58
+ const normalized = {
59
+ ...DEFAULT_ENFORCEMENT_POLICY,
60
+ ...policy,
61
+ allowed_generated_paths: Array.isArray(policy?.allowed_generated_paths) ? policy.allowed_generated_paths : [],
62
+ };
63
+ writeFileSync(policyPath, `${JSON.stringify(normalized, null, 2)}\n`);
64
+ return policyPath;
65
+ }
66
+
67
+ function buildSnapshotFingerprint(stats) {
68
+ return `${stats.size}:${Math.floor(stats.mtimeMs)}:${Math.floor(stats.ctimeMs)}`;
69
+ }
70
+
71
+ function buildWorktreeSnapshot(worktreePath, currentPath = worktreePath, snapshot = new Map()) {
72
+ const entries = readdirSync(currentPath, { withFileTypes: true });
73
+
74
+ for (const entry of entries) {
75
+ const absolutePath = join(currentPath, entry.name);
76
+ const relativePath = relative(worktreePath, absolutePath).replace(/\\/g, '/');
77
+ if (!relativePath || isIgnoredPath(relativePath)) {
78
+ continue;
79
+ }
80
+
81
+ if (entry.isDirectory()) {
82
+ buildWorktreeSnapshot(worktreePath, absolutePath, snapshot);
83
+ continue;
84
+ }
85
+
86
+ if (entry.isSymbolicLink()) {
87
+ try {
88
+ const target = readlinkSync(absolutePath);
89
+ snapshot.set(relativePath, `symlink:${target}`);
90
+ } catch {
91
+ snapshot.set(relativePath, 'symlink:unresolved');
92
+ }
93
+ continue;
94
+ }
95
+
96
+ if (!entry.isFile()) continue;
97
+
98
+ const stats = statSync(absolutePath);
99
+ snapshot.set(relativePath, buildSnapshotFingerprint(stats));
100
+ }
101
+
102
+ return snapshot;
103
+ }
104
+
105
+ function diffSnapshots(previousSnapshot, currentSnapshot) {
106
+ const changes = [];
107
+ const allPaths = new Set([
108
+ ...previousSnapshot.keys(),
109
+ ...currentSnapshot.keys(),
110
+ ]);
111
+
112
+ for (const filePath of allPaths) {
113
+ const previous = previousSnapshot.get(filePath);
114
+ const current = currentSnapshot.get(filePath);
115
+ if (previous == null && current != null) {
116
+ changes.push({ file_path: filePath, change_type: 'added' });
117
+ } else if (previous != null && current == null) {
118
+ changes.push({ file_path: filePath, change_type: 'deleted' });
119
+ } else if (previous !== current) {
120
+ changes.push({ file_path: filePath, change_type: 'modified' });
121
+ }
122
+ }
123
+
124
+ return changes.sort((a, b) => a.file_path.localeCompare(b.file_path));
125
+ }
126
+
127
+ function getLeaseScopePatterns(db, lease) {
128
+ return getTaskSpec(db, lease.task_id)?.allowed_paths || [];
129
+ }
130
+
131
+ function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
132
+ for (const lease of leases) {
133
+ if (excludeLeaseId && lease.id === excludeLeaseId) continue;
134
+ const patterns = getLeaseScopePatterns(db, lease);
135
+ if (patterns.length > 0 && matchesPathPatterns(filePath, patterns)) {
136
+ return lease;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function normalizeDirectoryScopeRoot(pattern) {
143
+ return String(pattern || '').replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/+$/, '');
144
+ }
145
+
146
+ function scopeAllowsDirectory(patterns, directoryPath) {
147
+ const normalizedDir = String(directoryPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
148
+ return patterns.some((pattern) => {
149
+ const scopeRoot = normalizeDirectoryScopeRoot(pattern);
150
+ return scopeRoot === normalizedDir || scopeRoot.startsWith(`${normalizedDir}/`) || normalizedDir.startsWith(`${scopeRoot}/`);
151
+ });
152
+ }
153
+
154
+ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeases) {
155
+ const ownClaim = activeClaims.find((claim) => claim.file_path === filePath && claim.lease_id === lease.id);
156
+ if (ownClaim) {
157
+ return { ok: true, reason_code: null, claim: ownClaim, ownership_type: 'claim' };
158
+ }
159
+
160
+ const foreignClaim = activeClaims.find((claim) => claim.file_path === filePath && claim.lease_id && claim.lease_id !== lease.id);
161
+ if (foreignClaim) {
162
+ return { ok: false, reason_code: 'path_claimed_by_other_lease', claim: foreignClaim, ownership_type: null };
163
+ }
164
+
165
+ const ownScopePatterns = getLeaseScopePatterns(db, lease);
166
+ const ownScopeMatch = ownScopePatterns.length > 0 && matchesPathPatterns(filePath, ownScopePatterns);
167
+ if (ownScopeMatch) {
168
+ const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
169
+ if (foreignScopeOwner) {
170
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
171
+ }
172
+ return { ok: true, reason_code: 'path_within_task_scope', claim: null, ownership_type: 'scope' };
173
+ }
174
+
175
+ const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
176
+ if (foreignScopeOwner) {
177
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
178
+ }
179
+
180
+ return { ok: false, reason_code: 'path_not_claimed', claim: null, ownership_type: null };
181
+ }
182
+
183
+ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
184
+ const activeLeases = options.activeLeases || listLeases(db, 'active');
185
+ const activeClaims = options.activeClaims || getActiveFileClaims(db);
186
+ const staleLeaseIds = options.staleLeaseIds || new Set(getStaleLeases(db).map((lease) => lease.id));
187
+ const policy = options.policy || loadEnforcementPolicy(repoRoot);
188
+
189
+ const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
190
+ const claim = activeClaims.find((item) => item.file_path === filePath && item.worktree === worktree.name) || null;
191
+
192
+ if (!activeLease) {
193
+ return { status: 'denied', reason_code: 'no_active_lease', lease: null, claim };
194
+ }
195
+ if (staleLeaseIds.has(activeLease.id)) {
196
+ return { status: 'denied', reason_code: 'lease_expired', lease: activeLease, claim };
197
+ }
198
+ const ownership = resolveLeasePathOwnership(db, activeLease, filePath, activeClaims, activeLeases);
199
+ if (ownership.ok) {
200
+ return {
201
+ status: 'allowed',
202
+ reason_code: ownership.reason_code,
203
+ lease: activeLease,
204
+ claim: ownership.claim ?? claim,
205
+ ownership_type: ownership.ownership_type,
206
+ };
207
+ }
208
+ if (matchesPathPatterns(filePath, policy.allowed_generated_paths || [])) {
209
+ return { status: 'allowed', reason_code: 'policy_exception_allowed', lease: activeLease, claim: null, ownership_type: 'policy' };
210
+ }
211
+ return { status: 'denied', reason_code: ownership.reason_code, lease: activeLease, claim: ownership.claim ?? claim, ownership_type: null };
212
+ }
213
+
214
+ function normalizeRepoPath(repoRoot, targetPath) {
215
+ const relativePath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '');
216
+ if (
217
+ relativePath === '' ||
218
+ relativePath.startsWith('..') ||
219
+ relativePath === '.' ||
220
+ relativePath.startsWith('/')
221
+ ) {
222
+ throw new Error('Target path must point to a file inside the repository.');
223
+ }
224
+ return {
225
+ absolutePath: resolve(repoRoot, relativePath),
226
+ relativePath,
227
+ };
228
+ }
229
+
230
+ export function validateWriteAccess(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
231
+ let normalized;
232
+ try {
233
+ normalized = normalizeRepoPath(repoRoot, targetPath);
234
+ } catch {
235
+ return {
236
+ ok: false,
237
+ reason_code: 'policy_exception_required',
238
+ file_path: targetPath,
239
+ lease: null,
240
+ claim: null,
241
+ };
242
+ }
243
+
244
+ const lease = getLease(db, leaseId);
245
+ if (!lease || lease.status !== 'active') {
246
+ return {
247
+ ok: false,
248
+ reason_code: 'no_active_lease',
249
+ file_path: normalized.relativePath,
250
+ lease: lease || null,
251
+ claim: null,
252
+ };
253
+ }
254
+
255
+ const staleLeaseIds = new Set(getStaleLeases(db).map((item) => item.id));
256
+ if (staleLeaseIds.has(lease.id)) {
257
+ return {
258
+ ok: false,
259
+ reason_code: 'lease_expired',
260
+ file_path: normalized.relativePath,
261
+ lease,
262
+ claim: null,
263
+ };
264
+ }
265
+
266
+ if (worktree && lease.worktree !== worktree) {
267
+ return {
268
+ ok: false,
269
+ reason_code: 'worktree_mismatch',
270
+ file_path: normalized.relativePath,
271
+ lease,
272
+ claim: null,
273
+ };
274
+ }
275
+
276
+ const leaseWorktree = getWorktree(db, lease.worktree);
277
+ if (!leaseWorktree) {
278
+ return {
279
+ ok: false,
280
+ reason_code: 'worktree_mismatch',
281
+ file_path: normalized.relativePath,
282
+ lease,
283
+ claim: null,
284
+ };
285
+ }
286
+
287
+ normalized.absolutePath = join(leaseWorktree.path, normalized.relativePath);
288
+ const activeLeases = listLeases(db, 'active');
289
+ const activeClaims = getActiveFileClaims(db).filter((claim) => claim.file_path === normalized.relativePath);
290
+ const ownership = resolveLeasePathOwnership(db, lease, normalized.relativePath, activeClaims, activeLeases);
291
+ if (ownership.ok) {
292
+ return {
293
+ ok: true,
294
+ reason_code: ownership.reason_code,
295
+ file_path: normalized.relativePath,
296
+ absolute_path: normalized.absolutePath,
297
+ lease,
298
+ claim: ownership.claim,
299
+ ownership_type: ownership.ownership_type,
300
+ };
301
+ }
302
+
303
+ return {
304
+ ok: false,
305
+ reason_code: ownership.reason_code,
306
+ file_path: normalized.relativePath,
307
+ lease,
308
+ claim: ownership.claim,
309
+ };
310
+ }
311
+
312
+ export function validateLeaseAccess(db, { leaseId, worktree = null }) {
313
+ const lease = getLease(db, leaseId);
314
+ if (!lease || lease.status !== 'active') {
315
+ return {
316
+ ok: false,
317
+ reason_code: 'no_active_lease',
318
+ lease: lease || null,
319
+ worktree: null,
320
+ };
321
+ }
322
+
323
+ const staleLeaseIds = new Set(getStaleLeases(db).map((item) => item.id));
324
+ if (staleLeaseIds.has(lease.id)) {
325
+ return {
326
+ ok: false,
327
+ reason_code: 'lease_expired',
328
+ lease,
329
+ worktree: null,
330
+ };
331
+ }
332
+
333
+ if (worktree && lease.worktree !== worktree) {
334
+ return {
335
+ ok: false,
336
+ reason_code: 'worktree_mismatch',
337
+ lease,
338
+ worktree: null,
339
+ };
340
+ }
341
+
342
+ const leaseWorktree = getWorktree(db, lease.worktree);
343
+ if (!leaseWorktree) {
344
+ return {
345
+ ok: false,
346
+ reason_code: 'worktree_mismatch',
347
+ lease,
348
+ worktree: null,
349
+ };
350
+ }
351
+
352
+ return {
353
+ ok: true,
354
+ reason_code: null,
355
+ lease,
356
+ worktree: leaseWorktree,
357
+ };
358
+ }
359
+
360
+ function logWriteEvent(db, status, reasonCode, validation, eventType, details = null) {
361
+ logAuditEvent(db, {
362
+ eventType,
363
+ status,
364
+ reasonCode,
365
+ worktree: validation.lease?.worktree ?? null,
366
+ taskId: validation.lease?.task_id ?? null,
367
+ leaseId: validation.lease?.id ?? null,
368
+ filePath: validation.file_path ?? null,
369
+ details: JSON.stringify({
370
+ ownership_type: validation.ownership_type ?? null,
371
+ ...(details || {}),
372
+ }),
373
+ });
374
+ }
375
+
376
+ function isTrackedByGit(worktreePath, filePath) {
377
+ try {
378
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', filePath], {
379
+ cwd: worktreePath,
380
+ stdio: 'ignore',
381
+ });
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ function restoreTrackedPath(worktreePath, filePath) {
389
+ execFileSync('git', ['checkout', '--', filePath], {
390
+ cwd: worktreePath,
391
+ stdio: 'ignore',
392
+ });
393
+ }
394
+
395
+ function quarantinePath(repoRoot, worktree, filePath, absolutePath) {
396
+ const quarantineRoot = join(repoRoot, '.switchman', 'quarantine', `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, worktree.name);
397
+ const quarantinePath = join(quarantineRoot, filePath);
398
+ mkdirSync(dirname(quarantinePath), { recursive: true });
399
+ renameSync(absolutePath, quarantinePath);
400
+ return quarantinePath;
401
+ }
402
+
403
+ function enforceObservedChange(repoRoot, worktree, change, classification) {
404
+ const absolutePath = join(worktree.path, change.file_path);
405
+ if (!existsSync(absolutePath)) {
406
+ if (change.change_type === 'deleted' && isTrackedByGit(worktree.path, change.file_path)) {
407
+ restoreTrackedPath(worktree.path, change.file_path);
408
+ return { action: 'restored', quarantine_path: null };
409
+ }
410
+ return { action: 'none', quarantine_path: null };
411
+ }
412
+
413
+ const quarantinePathValue = quarantinePath(repoRoot, worktree, change.file_path, absolutePath);
414
+ if (change.change_type === 'modified' && isTrackedByGit(worktree.path, change.file_path)) {
415
+ restoreTrackedPath(worktree.path, change.file_path);
416
+ return { action: 'quarantined_and_restored', quarantine_path: quarantinePathValue };
417
+ }
418
+ return { action: 'quarantined', quarantine_path: quarantinePathValue };
419
+ }
420
+
421
+ export function gatewayWriteFile(db, repoRoot, { leaseId, path: targetPath, content, worktree = null }) {
422
+ const validation = validateWriteAccess(db, repoRoot, { leaseId, path: targetPath, worktree });
423
+ if (!validation.ok) {
424
+ logWriteEvent(db, 'denied', validation.reason_code, validation, 'write_denied');
425
+ return {
426
+ ok: false,
427
+ reason_code: validation.reason_code,
428
+ file_path: validation.file_path,
429
+ lease_id: validation.lease?.id ?? leaseId,
430
+ };
431
+ }
432
+
433
+ mkdirSync(dirname(validation.absolute_path), { recursive: true });
434
+ writeFileSync(validation.absolute_path, content);
435
+ logWriteEvent(db, 'allowed', null, validation, 'write_allowed', { operation: 'replace' });
436
+
437
+ return {
438
+ ok: true,
439
+ file_path: validation.file_path,
440
+ lease_id: validation.lease.id,
441
+ bytes_written: Buffer.byteLength(content),
442
+ };
443
+ }
444
+
445
+ export function gatewayAppendFile(db, repoRoot, { leaseId, path: targetPath, content, worktree = null }) {
446
+ const validation = validateWriteAccess(db, repoRoot, { leaseId, path: targetPath, worktree });
447
+ if (!validation.ok) {
448
+ logWriteEvent(db, 'denied', validation.reason_code, validation, 'write_denied');
449
+ return {
450
+ ok: false,
451
+ reason_code: validation.reason_code,
452
+ file_path: validation.file_path,
453
+ lease_id: validation.lease?.id ?? leaseId,
454
+ };
455
+ }
456
+
457
+ mkdirSync(dirname(validation.absolute_path), { recursive: true });
458
+ appendFileSync(validation.absolute_path, content);
459
+ logWriteEvent(db, 'allowed', null, validation, 'write_allowed', { operation: 'append' });
460
+
461
+ return {
462
+ ok: true,
463
+ file_path: validation.file_path,
464
+ lease_id: validation.lease.id,
465
+ bytes_written: Buffer.byteLength(content),
466
+ };
467
+ }
468
+
469
+ export function gatewayRemovePath(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
470
+ const validation = validateWriteAccess(db, repoRoot, { leaseId, path: targetPath, worktree });
471
+ if (!validation.ok) {
472
+ logWriteEvent(db, 'denied', validation.reason_code, validation, 'write_denied');
473
+ return {
474
+ ok: false,
475
+ reason_code: validation.reason_code,
476
+ file_path: validation.file_path,
477
+ lease_id: validation.lease?.id ?? leaseId,
478
+ };
479
+ }
480
+
481
+ rmSync(validation.absolute_path, { force: true, recursive: true });
482
+ logWriteEvent(db, 'allowed', null, validation, 'write_allowed', { operation: 'remove' });
483
+
484
+ return {
485
+ ok: true,
486
+ file_path: validation.file_path,
487
+ lease_id: validation.lease.id,
488
+ removed: true,
489
+ };
490
+ }
491
+
492
+ export function gatewayMovePath(db, repoRoot, { leaseId, sourcePath, destinationPath, worktree = null }) {
493
+ const sourceValidation = validateWriteAccess(db, repoRoot, { leaseId, path: sourcePath, worktree });
494
+ if (!sourceValidation.ok) {
495
+ logWriteEvent(db, 'denied', sourceValidation.reason_code, sourceValidation, 'write_denied');
496
+ return {
497
+ ok: false,
498
+ reason_code: sourceValidation.reason_code,
499
+ file_path: sourceValidation.file_path,
500
+ lease_id: sourceValidation.lease?.id ?? leaseId,
501
+ };
502
+ }
503
+
504
+ const destinationValidation = validateWriteAccess(db, repoRoot, { leaseId, path: destinationPath, worktree });
505
+ if (!destinationValidation.ok) {
506
+ logWriteEvent(db, 'denied', destinationValidation.reason_code, destinationValidation, 'write_denied');
507
+ return {
508
+ ok: false,
509
+ reason_code: destinationValidation.reason_code,
510
+ file_path: destinationValidation.file_path,
511
+ lease_id: destinationValidation.lease?.id ?? leaseId,
512
+ };
513
+ }
514
+
515
+ mkdirSync(dirname(destinationValidation.absolute_path), { recursive: true });
516
+ renameSync(sourceValidation.absolute_path, destinationValidation.absolute_path);
517
+ logWriteEvent(db, 'allowed', null, destinationValidation, 'write_allowed', {
518
+ operation: 'move',
519
+ source_path: sourceValidation.file_path,
520
+ });
521
+
522
+ return {
523
+ ok: true,
524
+ file_path: destinationValidation.file_path,
525
+ source_path: sourceValidation.file_path,
526
+ lease_id: destinationValidation.lease.id,
527
+ moved: true,
528
+ };
529
+ }
530
+
531
+ export function gatewayMakeDirectory(db, repoRoot, { leaseId, path: targetPath, worktree = null }) {
532
+ const normalizedPath = String(targetPath || '').replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/\/+$/, '');
533
+ const lease = getLease(db, leaseId);
534
+
535
+ if (!lease || lease.status !== 'active') {
536
+ return {
537
+ ok: false,
538
+ reason_code: 'no_active_lease',
539
+ file_path: normalizedPath,
540
+ lease_id: lease?.id ?? leaseId,
541
+ };
542
+ }
543
+
544
+ if (worktree && lease.worktree !== worktree) {
545
+ return {
546
+ ok: false,
547
+ reason_code: 'worktree_mismatch',
548
+ file_path: normalizedPath,
549
+ lease_id: lease.id,
550
+ };
551
+ }
552
+
553
+ const leaseWorktree = getWorktree(db, lease.worktree);
554
+ if (!leaseWorktree) {
555
+ return {
556
+ ok: false,
557
+ reason_code: 'worktree_mismatch',
558
+ file_path: normalizedPath,
559
+ lease_id: lease.id,
560
+ };
561
+ }
562
+
563
+ const claimedDescendant = getActiveFileClaims(db).find((claim) =>
564
+ claim.lease_id === lease.id && (
565
+ claim.file_path === normalizedPath ||
566
+ claim.file_path.startsWith(`${normalizedPath}/`)
567
+ ),
568
+ );
569
+ const scopedDescendant = scopeAllowsDirectory(getLeaseScopePatterns(db, lease), normalizedPath);
570
+
571
+ if (!claimedDescendant && !scopedDescendant) {
572
+ const validation = {
573
+ lease,
574
+ file_path: normalizedPath,
575
+ };
576
+ logWriteEvent(db, 'denied', 'path_not_claimed', validation, 'write_denied');
577
+ return {
578
+ ok: false,
579
+ reason_code: 'path_not_claimed',
580
+ file_path: normalizedPath,
581
+ lease_id: lease.id,
582
+ };
583
+ }
584
+
585
+ const absolutePath = join(leaseWorktree.path, normalizedPath);
586
+ mkdirSync(absolutePath, { recursive: true });
587
+ logWriteEvent(db, 'allowed', scopedDescendant && !claimedDescendant ? 'path_within_task_scope' : null, {
588
+ lease,
589
+ file_path: normalizedPath,
590
+ ownership_type: scopedDescendant && !claimedDescendant ? 'scope' : 'claim',
591
+ }, 'write_allowed', { operation: 'mkdir' });
592
+
593
+ return {
594
+ ok: true,
595
+ file_path: normalizedPath,
596
+ lease_id: lease.id,
597
+ created: true,
598
+ };
599
+ }
600
+
601
+ export function runWrappedCommand(
602
+ db,
603
+ repoRoot,
604
+ {
605
+ leaseId,
606
+ command,
607
+ args = [],
608
+ worktree = null,
609
+ cwd = null,
610
+ env = {},
611
+ },
612
+ ) {
613
+ const validation = validateLeaseAccess(db, { leaseId, worktree });
614
+ if (!validation.ok) {
615
+ logAuditEvent(db, {
616
+ eventType: 'wrapper_command',
617
+ status: 'denied',
618
+ reasonCode: validation.reason_code,
619
+ worktree: validation.lease?.worktree ?? null,
620
+ taskId: validation.lease?.task_id ?? null,
621
+ leaseId: validation.lease?.id ?? leaseId,
622
+ details: JSON.stringify({
623
+ phase: 'start',
624
+ command,
625
+ args,
626
+ }),
627
+ });
628
+ return {
629
+ ok: false,
630
+ reason_code: validation.reason_code,
631
+ lease_id: validation.lease?.id ?? leaseId,
632
+ exit_code: null,
633
+ };
634
+ }
635
+
636
+ const launchCwd = cwd || validation.worktree.path;
637
+ const launchEnv = {
638
+ ...process.env,
639
+ ...env,
640
+ SWITCHMAN_LEASE_ID: validation.lease.id,
641
+ SWITCHMAN_TASK_ID: validation.lease.task_id,
642
+ SWITCHMAN_WORKTREE: validation.lease.worktree,
643
+ SWITCHMAN_REPO_ROOT: repoRoot,
644
+ SWITCHMAN_WORKTREE_PATH: validation.worktree.path,
645
+ };
646
+
647
+ logAuditEvent(db, {
648
+ eventType: 'wrapper_command',
649
+ status: 'allowed',
650
+ worktree: validation.lease.worktree,
651
+ taskId: validation.lease.task_id,
652
+ leaseId: validation.lease.id,
653
+ details: JSON.stringify({
654
+ phase: 'start',
655
+ command,
656
+ args,
657
+ cwd: launchCwd,
658
+ }),
659
+ });
660
+
661
+ const result = spawnSync(command, args, {
662
+ cwd: launchCwd,
663
+ env: launchEnv,
664
+ stdio: 'inherit',
665
+ });
666
+
667
+ const wrappedOk = !result.error && result.status === 0;
668
+ const reasonCode = result.error
669
+ ? 'wrapper_launch_failed'
670
+ : (result.status === 0 ? null : 'wrapped_command_failed');
671
+
672
+ logAuditEvent(db, {
673
+ eventType: 'wrapper_command',
674
+ status: wrappedOk ? 'allowed' : 'denied',
675
+ reasonCode,
676
+ worktree: validation.lease.worktree,
677
+ taskId: validation.lease.task_id,
678
+ leaseId: validation.lease.id,
679
+ details: JSON.stringify({
680
+ phase: 'finish',
681
+ command,
682
+ args,
683
+ cwd: launchCwd,
684
+ exit_code: result.status,
685
+ signal: result.signal || null,
686
+ error: result.error?.message || null,
687
+ }),
688
+ });
689
+
690
+ return {
691
+ ok: wrappedOk,
692
+ reason_code: reasonCode,
693
+ lease_id: validation.lease.id,
694
+ task_id: validation.lease.task_id,
695
+ worktree: validation.lease.worktree,
696
+ exit_code: result.status,
697
+ signal: result.signal || null,
698
+ };
699
+ }
700
+
701
+ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {}) {
702
+ const staleLeaseIds = options.staleLeaseIds || new Set(getStaleLeases(db).map((lease) => lease.id));
703
+ const activeLeases = options.activeLeases || listLeases(db, 'active');
704
+ const activeClaims = options.activeClaims || getActiveFileClaims(db);
705
+ const completedClaims = options.completedClaims || getCompletedFileClaims(db, worktree.name);
706
+
707
+ const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
708
+ const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
709
+ const claimsForWorktree = activeClaims.filter((claim) => claim.worktree === worktree.name);
710
+ const completedClaimsByPath = new Map(completedClaims.map((claim) => [claim.file_path, claim]));
711
+
712
+ const violations = [];
713
+ const unclaimedChangedFiles = [];
714
+
715
+ for (const file of changedFiles) {
716
+ const completedClaim = completedClaimsByPath.get(file);
717
+ if (!activeLease) {
718
+ if (completedClaim) {
719
+ continue;
720
+ }
721
+ violations.push({ file, reason_code: 'no_active_lease' });
722
+ unclaimedChangedFiles.push(file);
723
+ continue;
724
+ }
725
+ if (staleLeaseIds.has(activeLease.id)) {
726
+ violations.push({ file, reason_code: 'lease_expired' });
727
+ unclaimedChangedFiles.push(file);
728
+ continue;
729
+ }
730
+ const ownership = resolveLeasePathOwnership(db, activeLease, file, activeClaims, activeLeases);
731
+ if (!ownership.ok) {
732
+ violations.push({ file, reason_code: ownership.reason_code });
733
+ unclaimedChangedFiles.push(file);
734
+ }
735
+ }
736
+
737
+ let complianceState = COMPLIANCE_STATES.OBSERVED;
738
+ if (activeLease && staleLeaseIds.has(activeLease.id)) {
739
+ complianceState = COMPLIANCE_STATES.STALE;
740
+ } else if (violations.length > 0) {
741
+ complianceState = COMPLIANCE_STATES.NON_COMPLIANT;
742
+ } else if (activeLease) {
743
+ complianceState = COMPLIANCE_STATES.MANAGED;
744
+ }
745
+
746
+ updateWorktreeCompliance(db, worktree.name, complianceState);
747
+
748
+ return {
749
+ worktree: worktree.name,
750
+ active_lease_id: activeLease?.id ?? null,
751
+ compliance_state: complianceState,
752
+ changed_files: changedFiles,
753
+ unclaimed_changed_files: unclaimedChangedFiles,
754
+ violations,
755
+ };
756
+ }
757
+
758
+ export function evaluateRepoCompliance(db, repoRoot, worktrees) {
759
+ const activeLeases = listLeases(db, 'active');
760
+ const activeClaims = getActiveFileClaims(db);
761
+ const completedClaims = getCompletedFileClaims(db);
762
+ const staleLeaseIds = new Set(getStaleLeases(db).map((lease) => lease.id));
763
+ const completedClaimsByWorktree = completedClaims.reduce((acc, claim) => {
764
+ if (!acc[claim.worktree]) acc[claim.worktree] = [];
765
+ acc[claim.worktree].push(claim);
766
+ return acc;
767
+ }, {});
768
+
769
+ const worktreeCompliance = worktrees.map((worktree) =>
770
+ evaluateWorktreeCompliance(db, repoRoot, worktree, {
771
+ activeLeases,
772
+ activeClaims,
773
+ completedClaims: completedClaimsByWorktree[worktree.name] || [],
774
+ staleLeaseIds,
775
+ }),
776
+ );
777
+
778
+ return {
779
+ worktreeCompliance,
780
+ unclaimedChanges: worktreeCompliance
781
+ .filter((entry) => entry.unclaimed_changed_files.length > 0)
782
+ .map((entry) => ({
783
+ worktree: entry.worktree,
784
+ lease_id: entry.active_lease_id,
785
+ files: entry.unclaimed_changed_files,
786
+ reasons: entry.violations,
787
+ })),
788
+ complianceSummary: {
789
+ managed: worktreeCompliance.filter((entry) => entry.compliance_state === COMPLIANCE_STATES.MANAGED).length,
790
+ observed: worktreeCompliance.filter((entry) => entry.compliance_state === COMPLIANCE_STATES.OBSERVED).length,
791
+ non_compliant: worktreeCompliance.filter((entry) => entry.compliance_state === COMPLIANCE_STATES.NON_COMPLIANT).length,
792
+ stale: worktreeCompliance.filter((entry) => entry.compliance_state === COMPLIANCE_STATES.STALE).length,
793
+ },
794
+ deniedWrites: listAuditEvents(db, { status: 'denied', limit: 20 }),
795
+ commitGateFailures: listAuditEvents(db, { eventType: 'commit_gate', status: 'denied', limit: 20 }),
796
+ };
797
+ }
798
+
799
+ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
800
+ const activeLeases = listLeases(db, 'active');
801
+ const activeClaims = getActiveFileClaims(db);
802
+ const staleLeaseIds = new Set(getStaleLeases(db).map((lease) => lease.id));
803
+ const policy = options.policy || loadEnforcementPolicy(repoRoot);
804
+
805
+ const events = [];
806
+
807
+ for (const worktree of worktrees) {
808
+ const previousSnapshot = getWorktreeSnapshotState(db, worktree.name);
809
+ const currentSnapshot = buildWorktreeSnapshot(worktree.path);
810
+ const changes = diffSnapshots(previousSnapshot, currentSnapshot);
811
+
812
+ for (const change of changes) {
813
+ const classification = classifyObservedPath(db, repoRoot, worktree, change.file_path, {
814
+ activeLeases,
815
+ activeClaims,
816
+ staleLeaseIds,
817
+ policy,
818
+ });
819
+
820
+ const event = {
821
+ worktree: worktree.name,
822
+ file_path: change.file_path,
823
+ change_type: change.change_type,
824
+ status: classification.status,
825
+ reason_code: classification.reason_code,
826
+ lease_id: classification.lease?.id ?? null,
827
+ task_id: classification.lease?.task_id ?? null,
828
+ enforcement_action: null,
829
+ quarantine_path: null,
830
+ };
831
+
832
+ logAuditEvent(db, {
833
+ eventType: 'write_observed',
834
+ status: classification.status,
835
+ reasonCode: classification.reason_code,
836
+ worktree: worktree.name,
837
+ taskId: classification.lease?.task_id ?? null,
838
+ leaseId: classification.lease?.id ?? null,
839
+ filePath: change.file_path,
840
+ details: JSON.stringify({ change_type: change.change_type }),
841
+ });
842
+
843
+ if (classification.status === 'denied') {
844
+ updateWorktreeCompliance(db, worktree.name, COMPLIANCE_STATES.NON_COMPLIANT);
845
+ if (options.quarantine) {
846
+ const enforcementResult = enforceObservedChange(repoRoot, worktree, change, classification);
847
+ event.enforcement_action = enforcementResult.action;
848
+ event.quarantine_path = enforcementResult.quarantine_path;
849
+ logAuditEvent(db, {
850
+ eventType: 'write_quarantined',
851
+ status: 'allowed',
852
+ reasonCode: classification.reason_code,
853
+ worktree: worktree.name,
854
+ taskId: classification.lease?.task_id ?? null,
855
+ leaseId: classification.lease?.id ?? null,
856
+ filePath: change.file_path,
857
+ details: JSON.stringify(enforcementResult),
858
+ });
859
+ }
860
+ }
861
+
862
+ events.push(event);
863
+ }
864
+
865
+ const finalSnapshot = options.quarantine ? buildWorktreeSnapshot(worktree.path) : currentSnapshot;
866
+ replaceWorktreeSnapshotState(db, worktree.name, finalSnapshot);
867
+ }
868
+
869
+ return {
870
+ events,
871
+ summary: {
872
+ total: events.length,
873
+ allowed: events.filter((event) => event.status === 'allowed').length,
874
+ denied: events.filter((event) => event.status === 'denied').length,
875
+ quarantined: events.filter((event) => event.enforcement_action && event.enforcement_action !== 'none').length,
876
+ },
877
+ };
878
+ }
879
+
880
+ export function runCommitGate(db, repoRoot, { cwd = process.cwd(), worktreeName = null } = {}) {
881
+ const currentWorktree = worktreeName
882
+ ? null
883
+ : getCurrentWorktree(repoRoot, cwd);
884
+ const resolvedWorktree = worktreeName
885
+ ? { name: worktreeName, path: cwd }
886
+ : currentWorktree;
887
+
888
+ if (!resolvedWorktree) {
889
+ const result = {
890
+ ok: false,
891
+ worktree: null,
892
+ changed_files: [],
893
+ violations: [{ file: null, reason_code: 'worktree_mismatch' }],
894
+ summary: 'Current directory is not a registered git worktree.',
895
+ };
896
+ logAuditEvent(db, {
897
+ eventType: 'commit_gate',
898
+ status: 'denied',
899
+ reasonCode: 'worktree_mismatch',
900
+ details: JSON.stringify(result),
901
+ });
902
+ return result;
903
+ }
904
+
905
+ const compliance = evaluateWorktreeCompliance(db, repoRoot, resolvedWorktree);
906
+ const ok = compliance.violations.length === 0;
907
+ const summary = ok
908
+ ? `Commit gate passed for ${resolvedWorktree.name}.`
909
+ : `Commit gate rejected ${compliance.violations.length} ungoverned file change(s) in ${resolvedWorktree.name}.`;
910
+
911
+ logAuditEvent(db, {
912
+ eventType: 'commit_gate',
913
+ status: ok ? 'allowed' : 'denied',
914
+ reasonCode: ok ? null : compliance.violations[0]?.reason_code ?? 'path_not_claimed',
915
+ worktree: resolvedWorktree.name,
916
+ leaseId: compliance.active_lease_id,
917
+ details: JSON.stringify({
918
+ changed_files: compliance.changed_files,
919
+ violations: compliance.violations,
920
+ }),
921
+ });
922
+
923
+ return {
924
+ ok,
925
+ worktree: resolvedWorktree.name,
926
+ lease_id: compliance.active_lease_id,
927
+ changed_files: compliance.changed_files,
928
+ violations: compliance.violations,
929
+ summary,
930
+ };
931
+ }
932
+
933
+ export function installCommitHook(repoRoot) {
934
+ const commonDir = getGitCommonDir(repoRoot);
935
+ const hooksDir = join(commonDir, 'hooks');
936
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
937
+
938
+ const hookPath = join(hooksDir, 'pre-commit');
939
+ const hookScript = `#!/bin/sh
940
+ switchman gate commit
941
+ `;
942
+ writeFileSync(hookPath, hookScript);
943
+ chmodSync(hookPath, 0o755);
944
+ return hookPath;
945
+ }
946
+
947
+ export function installMergeHook(repoRoot) {
948
+ const commonDir = getGitCommonDir(repoRoot);
949
+ const hooksDir = join(commonDir, 'hooks');
950
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
951
+
952
+ const hookPath = join(hooksDir, 'pre-merge-commit');
953
+ const hookScript = `#!/bin/sh
954
+ switchman gate merge
955
+ `;
956
+ writeFileSync(hookPath, hookScript);
957
+ chmodSync(hookPath, 0o755);
958
+ return hookPath;
959
+ }
960
+
961
+ export function installGateHooks(repoRoot) {
962
+ return {
963
+ pre_commit: installCommitHook(repoRoot),
964
+ pre_merge_commit: installMergeHook(repoRoot),
965
+ };
966
+ }