switchman-dev 0.1.7 → 0.1.9

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/ci.js CHANGED
@@ -273,7 +273,7 @@ jobs:
273
273
  - name: Setup Node
274
274
  uses: actions/setup-node@v4
275
275
  with:
276
- node-version: 20
276
+ node-version: 22
277
277
 
278
278
  - name: Install Switchman
279
279
  run: npm install -g switchman-dev
package/src/core/db.js CHANGED
@@ -14,7 +14,7 @@ const SWITCHMAN_DIR = '.switchman';
14
14
  const DB_FILE = 'switchman.db';
15
15
  const AUDIT_KEY_FILE = 'audit.key';
16
16
  const MIGRATION_STATE_FILE = 'migration-state.json';
17
- const CURRENT_SCHEMA_VERSION = 5;
17
+ const CURRENT_SCHEMA_VERSION = 6;
18
18
 
19
19
  // How long (ms) a writer will wait for a lock before giving up.
20
20
  // 5 seconds is generous for a CLI tool with 3-10 concurrent agents.
@@ -22,6 +22,7 @@ const BUSY_TIMEOUT_MS = 10000;
22
22
  const CLAIM_RETRY_DELAY_MS = 200;
23
23
  const CLAIM_RETRY_ATTEMPTS = 20;
24
24
  export const DEFAULT_STALE_LEASE_MINUTES = 15;
25
+ const DB_PRUNE_RETENTION_DAYS = 30;
25
26
 
26
27
  function sleepSync(ms) {
27
28
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -524,6 +525,24 @@ function applySchemaVersion5(db) {
524
525
  `);
525
526
  }
526
527
 
528
+ function applySchemaVersion6(db) {
529
+ db.exec(`
530
+ UPDATE file_claims
531
+ SET released_at = COALESCE(released_at, datetime('now'))
532
+ WHERE released_at IS NULL
533
+ AND id NOT IN (
534
+ SELECT MIN(id)
535
+ FROM file_claims
536
+ WHERE released_at IS NULL
537
+ GROUP BY file_path
538
+ );
539
+
540
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_file_claims_active_path
541
+ ON file_claims(file_path)
542
+ WHERE released_at IS NULL;
543
+ `);
544
+ }
545
+
527
546
  function ensureSchemaMigrated(db) {
528
547
  const repoRoot = db.__switchmanRepoRoot;
529
548
  if (!repoRoot) {
@@ -572,6 +591,9 @@ function ensureSchemaMigrated(db) {
572
591
  if (currentVersion < 5) {
573
592
  applySchemaVersion5(db);
574
593
  }
594
+ if (currentVersion < 6) {
595
+ applySchemaVersion6(db);
596
+ }
575
597
  setSchemaVersion(db, CURRENT_SCHEMA_VERSION);
576
598
  });
577
599
  clearMigrationState(repoRoot);
@@ -826,8 +848,8 @@ function reserveLeaseScopesTx(db, lease) {
826
848
  const conflicts = findScopeReservationConflicts(reservations, activeReservations);
827
849
  if (conflicts.length > 0) {
828
850
  const summary = conflicts[0].type === 'subsystem'
829
- ? `subsystem:${conflicts[0].subsystem_tag}`
830
- : `${conflicts[0].scope_pattern} overlaps ${conflicts[0].conflicting_scope_pattern}`;
851
+ ? `${conflicts[0].worktree} already owns subsystem:${conflicts[0].subsystem_tag}`
852
+ : `${conflicts[0].worktree} already owns ${conflicts[0].conflicting_scope_pattern} (requested ${conflicts[0].scope_pattern})`;
831
853
  logAuditEventTx(db, {
832
854
  eventType: 'scope_reservation_denied',
833
855
  status: 'denied',
@@ -837,7 +859,7 @@ function reserveLeaseScopesTx(db, lease) {
837
859
  leaseId: lease.id,
838
860
  details: JSON.stringify({ conflicts, summary }),
839
861
  });
840
- throw new Error(`Scope reservation conflict: ${summary}`);
862
+ throw new Error(`Scope ownership conflict: ${summary}`);
841
863
  }
842
864
 
843
865
  const insert = db.prepare(`
@@ -1672,20 +1694,27 @@ export function createTask(db, { id, title, description, priority = 5 }) {
1672
1694
  }
1673
1695
 
1674
1696
  export function startTaskLease(db, taskId, worktree, agent) {
1675
- return withImmediateTransaction(db, () => {
1676
- const task = getTaskTx(db, taskId);
1677
- if (!task || task.status !== 'pending') {
1678
- return null;
1679
- }
1697
+ try {
1698
+ return withImmediateTransaction(db, () => {
1699
+ const task = getTaskTx(db, taskId);
1700
+ if (!task || task.status !== 'pending') {
1701
+ return null;
1702
+ }
1680
1703
 
1681
- db.prepare(`
1682
- UPDATE tasks
1683
- SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
1684
- WHERE id=? AND status='pending'
1685
- `).run(worktree, agent || null, taskId);
1704
+ db.prepare(`
1705
+ UPDATE tasks
1706
+ SET status='in_progress', worktree=?, agent=?, updated_at=datetime('now')
1707
+ WHERE id=? AND status='pending'
1708
+ `).run(worktree, agent || null, taskId);
1686
1709
 
1687
- return createLeaseTx(db, { taskId, worktree, agent });
1688
- });
1710
+ return createLeaseTx(db, { taskId, worktree, agent });
1711
+ });
1712
+ } catch (err) {
1713
+ if (String(err?.message || '').startsWith('Scope ownership conflict:')) {
1714
+ return null;
1715
+ }
1716
+ throw err;
1717
+ }
1689
1718
  }
1690
1719
 
1691
1720
  export function assignTask(db, taskId, worktree, agent) {
@@ -1693,14 +1722,52 @@ export function assignTask(db, taskId, worktree, agent) {
1693
1722
  }
1694
1723
 
1695
1724
  export function completeTask(db, taskId) {
1696
- withImmediateTransaction(db, () => {
1725
+ return withImmediateTransaction(db, () => {
1726
+ const task = getTaskTx(db, taskId);
1727
+ if (!task) {
1728
+ throw new Error(`Task ${taskId} does not exist.`);
1729
+ }
1730
+ if (task.status === 'done') {
1731
+ return {
1732
+ ok: false,
1733
+ status: 'already_done',
1734
+ task: getTaskTx(db, taskId),
1735
+ };
1736
+ }
1737
+ if (task.status === 'failed') {
1738
+ return {
1739
+ ok: false,
1740
+ status: 'failed',
1741
+ task: getTaskTx(db, taskId),
1742
+ };
1743
+ }
1744
+ if (task.status !== 'in_progress') {
1745
+ return {
1746
+ ok: false,
1747
+ status: 'not_in_progress',
1748
+ task: getTaskTx(db, taskId),
1749
+ };
1750
+ }
1697
1751
  const activeLease = getActiveLeaseForTaskTx(db, taskId);
1752
+ if (!activeLease) {
1753
+ return {
1754
+ ok: false,
1755
+ status: 'no_active_lease',
1756
+ task: getTaskTx(db, taskId),
1757
+ };
1758
+ }
1698
1759
  finalizeTaskWithLeaseTx(db, taskId, activeLease, {
1699
1760
  taskStatus: 'done',
1700
1761
  leaseStatus: 'completed',
1701
1762
  auditStatus: 'allowed',
1702
1763
  auditEventType: 'task_completed',
1703
1764
  });
1765
+ return {
1766
+ ok: true,
1767
+ status: 'completed',
1768
+ had_active_lease: true,
1769
+ task: getTaskTx(db, taskId),
1770
+ };
1704
1771
  });
1705
1772
  }
1706
1773
 
@@ -1755,7 +1822,13 @@ export function failLeaseTask(db, leaseId, reason) {
1755
1822
  export function retryTask(db, taskId, reason = null) {
1756
1823
  return withImmediateTransaction(db, () => {
1757
1824
  const task = getTaskTx(db, taskId);
1758
- if (!task || !['failed', 'done'].includes(task.status)) {
1825
+ if (!task) {
1826
+ return null;
1827
+ }
1828
+ const activeLease = getActiveLeaseForTaskTx(db, taskId);
1829
+ const retryable = ['failed', 'done'].includes(task.status)
1830
+ || (task.status === 'in_progress' && !activeLease);
1831
+ if (!retryable) {
1759
1832
  return null;
1760
1833
  }
1761
1834
 
@@ -1766,7 +1839,7 @@ export function retryTask(db, taskId, reason = null) {
1766
1839
  agent=NULL,
1767
1840
  completed_at=NULL,
1768
1841
  updated_at=datetime('now')
1769
- WHERE id=? AND status IN ('failed', 'done')
1842
+ WHERE id=? AND status IN ('failed', 'done', 'in_progress')
1770
1843
  `).run(taskId);
1771
1844
 
1772
1845
  logAuditEventTx(db, {
@@ -2624,7 +2697,7 @@ export function getStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINUT
2624
2697
  FROM leases l
2625
2698
  JOIN tasks t ON l.task_id = t.id
2626
2699
  WHERE l.status='active'
2627
- AND l.heartbeat_at < datetime('now', ?)
2700
+ AND l.heartbeat_at <= datetime('now', ?)
2628
2701
  ORDER BY l.heartbeat_at ASC
2629
2702
  `).all(`-${staleAfterMinutes} minutes`);
2630
2703
  }
@@ -2699,6 +2772,44 @@ export function reapStaleLeases(db, staleAfterMinutes = DEFAULT_STALE_LEASE_MINU
2699
2772
  });
2700
2773
  }
2701
2774
 
2775
+ export function pruneDatabaseMaintenance(db, { retentionDays = DB_PRUNE_RETENTION_DAYS } = {}) {
2776
+ const retentionWindow = `-${Math.max(1, Number.parseInt(retentionDays, 10) || DB_PRUNE_RETENTION_DAYS)} days`;
2777
+ return withImmediateTransaction(db, () => {
2778
+ const releasedClaims = db.prepare(`
2779
+ DELETE FROM file_claims
2780
+ WHERE released_at IS NOT NULL
2781
+ AND released_at <= datetime('now', ?)
2782
+ `).run(retentionWindow).changes;
2783
+
2784
+ const releasedReservations = db.prepare(`
2785
+ DELETE FROM scope_reservations
2786
+ WHERE released_at IS NOT NULL
2787
+ AND released_at <= datetime('now', ?)
2788
+ `).run(retentionWindow).changes;
2789
+
2790
+ const finishedLeases = db.prepare(`
2791
+ DELETE FROM leases
2792
+ WHERE status != 'active'
2793
+ AND finished_at IS NOT NULL
2794
+ AND finished_at <= datetime('now', ?)
2795
+ `).run(retentionWindow).changes;
2796
+
2797
+ const orphanedSnapshots = db.prepare(`
2798
+ DELETE FROM worktree_snapshots
2799
+ WHERE worktree NOT IN (
2800
+ SELECT name FROM worktrees
2801
+ )
2802
+ `).run().changes;
2803
+
2804
+ return {
2805
+ released_claims_pruned: releasedClaims,
2806
+ finished_leases_pruned: finishedLeases,
2807
+ released_scope_reservations_pruned: releasedReservations,
2808
+ orphaned_snapshots_pruned: orphanedSnapshots,
2809
+ };
2810
+ });
2811
+ }
2812
+
2702
2813
  // ─── File Claims ──────────────────────────────────────────────────────────────
2703
2814
 
2704
2815
  export function claimFiles(db, taskId, worktree, filePaths, agent) {
@@ -2746,7 +2857,18 @@ export function claimFiles(db, taskId, worktree, filePaths, agent) {
2746
2857
  throw new Error('One or more files are already actively claimed by another task.');
2747
2858
  }
2748
2859
 
2749
- insert.run(taskId, lease.id, normalizedPath, worktree, agent || null);
2860
+ try {
2861
+ insert.run(taskId, lease.id, normalizedPath, worktree, agent || null);
2862
+ } catch (err) {
2863
+ const message = String(err?.message || '').toLowerCase();
2864
+ const isActiveClaimConstraint =
2865
+ message.includes('idx_file_claims_active_path')
2866
+ || (message.includes('unique') && message.includes('file_claims'));
2867
+ if (isActiveClaimConstraint) {
2868
+ throw new Error('One or more files are already actively claimed by another task.');
2869
+ }
2870
+ throw err;
2871
+ }
2750
2872
  activeClaimByPath.set(normalizedPath, {
2751
2873
  task_id: taskId,
2752
2874
  lease_id: lease.id,
@@ -1,18 +1,23 @@
1
- import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'fs';
2
2
  import { dirname, join, posix, relative, resolve } from 'path';
3
3
  import { execFileSync, spawnSync } from 'child_process';
4
4
 
5
5
  import {
6
6
  getActiveFileClaims,
7
+ touchBoundaryValidationState,
7
8
  getCompletedFileClaims,
8
9
  getLease,
9
- getTaskSpec,
10
10
  getWorktree,
11
+ listWorktrees,
11
12
  getWorktreeSnapshotState,
12
13
  replaceWorktreeSnapshotState,
13
14
  getStaleLeases,
15
+ listScopeReservations,
14
16
  listAuditEvents,
15
17
  listLeases,
18
+ listTasks,
19
+ getTask,
20
+ getTaskSpec,
16
21
  logAuditEvent,
17
22
  updateWorktreeCompliance,
18
23
  } from './db.js';
@@ -30,6 +35,14 @@ const DEFAULT_ENFORCEMENT_POLICY = {
30
35
  allowed_generated_paths: [],
31
36
  };
32
37
 
38
+ function normalizeFsPath(filePath) {
39
+ try {
40
+ return realpathSync(filePath);
41
+ } catch {
42
+ return resolve(filePath);
43
+ }
44
+ }
45
+
33
46
  function getEnforcementPolicyPath(repoRoot) {
34
47
  return join(repoRoot, '.switchman', 'enforcement.json');
35
48
  }
@@ -125,7 +138,19 @@ function diffSnapshots(previousSnapshot, currentSnapshot) {
125
138
  }
126
139
 
127
140
  function getLeaseScopePatterns(db, lease) {
128
- return getTaskSpec(db, lease.task_id)?.allowed_paths || [];
141
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
142
+ const scopePatterns = reservations
143
+ .filter((reservation) => reservation.ownership_level === 'path_scope' && reservation.scope_pattern)
144
+ .map((reservation) => reservation.scope_pattern);
145
+ return [...new Set(scopePatterns)];
146
+ }
147
+
148
+ function getLeaseSubsystemTags(db, lease) {
149
+ const reservations = listScopeReservations(db, { leaseId: lease.id });
150
+ const subsystemTags = reservations
151
+ .filter((reservation) => reservation.ownership_level === 'subsystem' && reservation.subsystem_tag)
152
+ .map((reservation) => reservation.subsystem_tag);
153
+ return [...new Set(subsystemTags)];
129
154
  }
130
155
 
131
156
  function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
@@ -139,6 +164,18 @@ function findScopedLeaseOwner(db, leases, filePath, excludeLeaseId = null) {
139
164
  return null;
140
165
  }
141
166
 
167
+ function findSubsystemLeaseOwner(db, leases, subsystemTags, excludeLeaseId = null) {
168
+ if (!subsystemTags.length) return null;
169
+ for (const lease of leases) {
170
+ if (excludeLeaseId && lease.id === excludeLeaseId) continue;
171
+ const leaseTags = getLeaseSubsystemTags(db, lease);
172
+ if (leaseTags.some((tag) => subsystemTags.includes(tag))) {
173
+ return lease;
174
+ }
175
+ }
176
+ return null;
177
+ }
178
+
142
179
  function normalizeDirectoryScopeRoot(pattern) {
143
180
  return String(pattern || '').replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/+$/, '');
144
181
  }
@@ -163,8 +200,13 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
163
200
  }
164
201
 
165
202
  const ownScopePatterns = getLeaseScopePatterns(db, lease);
203
+ const ownSubsystemTags = getLeaseSubsystemTags(db, lease);
166
204
  const ownScopeMatch = ownScopePatterns.length > 0 && matchesPathPatterns(filePath, ownScopePatterns);
167
205
  if (ownScopeMatch) {
206
+ const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
207
+ if (foreignSubsystemOwner) {
208
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
209
+ }
168
210
  const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, lease.id);
169
211
  if (foreignScopeOwner) {
170
212
  return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
@@ -177,9 +219,25 @@ function resolveLeasePathOwnership(db, lease, filePath, activeClaims, activeLeas
177
219
  return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
178
220
  }
179
221
 
222
+ const foreignSubsystemOwner = findSubsystemLeaseOwner(db, activeLeases, ownSubsystemTags, lease.id);
223
+ if (foreignSubsystemOwner) {
224
+ return { ok: false, reason_code: 'path_scoped_by_other_lease', claim: null, ownership_type: null };
225
+ }
226
+
180
227
  return { ok: false, reason_code: 'path_not_claimed', claim: null, ownership_type: null };
181
228
  }
182
229
 
230
+ function completedScopedTaskOwnsPath(db, completedTasks, filePath) {
231
+ for (const task of completedTasks) {
232
+ const taskSpec = getTaskSpec(db, task.id);
233
+ const allowedPaths = Array.isArray(taskSpec?.allowed_paths) ? taskSpec.allowed_paths : [];
234
+ if (allowedPaths.length > 0 && matchesPathPatterns(filePath, allowedPaths)) {
235
+ return true;
236
+ }
237
+ }
238
+ return false;
239
+ }
240
+
183
241
  function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
184
242
  const activeLeases = options.activeLeases || listLeases(db, 'active');
185
243
  const activeClaims = options.activeClaims || getActiveFileClaims(db);
@@ -188,12 +246,28 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
188
246
 
189
247
  const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
190
248
  const claim = activeClaims.find((item) => item.file_path === filePath && item.worktree === worktree.name) || null;
249
+ const foreignClaim = activeClaims.find((item) => item.file_path === filePath && item.worktree !== worktree.name) || null;
250
+ const foreignScopeOwner = findScopedLeaseOwner(db, activeLeases, filePath, activeLease?.id || null);
191
251
 
192
252
  if (!activeLease) {
193
- return { status: 'denied', reason_code: 'no_active_lease', lease: null, claim };
253
+ return {
254
+ status: 'denied',
255
+ reason_code: 'no_active_lease',
256
+ lease: null,
257
+ claim,
258
+ owner_claim: foreignClaim,
259
+ owner_lease: foreignScopeOwner,
260
+ };
194
261
  }
195
262
  if (staleLeaseIds.has(activeLease.id)) {
196
- return { status: 'denied', reason_code: 'lease_expired', lease: activeLease, claim };
263
+ return {
264
+ status: 'denied',
265
+ reason_code: 'lease_expired',
266
+ lease: activeLease,
267
+ claim,
268
+ owner_claim: foreignClaim,
269
+ owner_lease: foreignScopeOwner,
270
+ };
197
271
  }
198
272
  const ownership = resolveLeasePathOwnership(db, activeLease, filePath, activeClaims, activeLeases);
199
273
  if (ownership.ok) {
@@ -203,12 +277,30 @@ function classifyObservedPath(db, repoRoot, worktree, filePath, options = {}) {
203
277
  lease: activeLease,
204
278
  claim: ownership.claim ?? claim,
205
279
  ownership_type: ownership.ownership_type,
280
+ owner_claim: null,
281
+ owner_lease: null,
206
282
  };
207
283
  }
208
284
  if (matchesPathPatterns(filePath, policy.allowed_generated_paths || [])) {
209
- return { status: 'allowed', reason_code: 'policy_exception_allowed', lease: activeLease, claim: null, ownership_type: 'policy' };
285
+ return {
286
+ status: 'allowed',
287
+ reason_code: 'policy_exception_allowed',
288
+ lease: activeLease,
289
+ claim: null,
290
+ ownership_type: 'policy',
291
+ owner_claim: null,
292
+ owner_lease: null,
293
+ };
210
294
  }
211
- return { status: 'denied', reason_code: ownership.reason_code, lease: activeLease, claim: ownership.claim ?? claim, ownership_type: null };
295
+ return {
296
+ status: 'denied',
297
+ reason_code: ownership.reason_code,
298
+ lease: activeLease,
299
+ claim: ownership.claim ?? claim,
300
+ ownership_type: null,
301
+ owner_claim: ownership.claim ?? foreignClaim,
302
+ owner_lease: foreignScopeOwner,
303
+ };
212
304
  }
213
305
 
214
306
  function normalizeRepoPath(repoRoot, targetPath) {
@@ -361,6 +453,9 @@ export function validateLeaseAccess(db, { leaseId, worktree = null }) {
361
453
  }
362
454
 
363
455
  function logWriteEvent(db, status, reasonCode, validation, eventType, details = null) {
456
+ if (status === 'allowed' && validation.lease?.id) {
457
+ touchBoundaryValidationState(db, validation.lease.id, `write:${details?.operation || 'unknown'}`);
458
+ }
364
459
  logAuditEvent(db, {
365
460
  eventType,
366
461
  status,
@@ -716,6 +811,9 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
716
811
  const activeLeases = options.activeLeases || listLeases(db, 'active');
717
812
  const activeClaims = options.activeClaims || getActiveFileClaims(db);
718
813
  const completedClaims = options.completedClaims || getCompletedFileClaims(db, worktree.name);
814
+ const completedTasks = options.completedTasks
815
+ || listTasks(db)
816
+ .filter((task) => task.status === 'done' && task.worktree === worktree.name);
719
817
 
720
818
  const changedFiles = getWorktreeChangedFiles(worktree.path, repoRoot);
721
819
  const activeLease = activeLeases.find((lease) => lease.worktree === worktree.name) || null;
@@ -728,7 +826,7 @@ export function evaluateWorktreeCompliance(db, repoRoot, worktree, options = {})
728
826
  for (const file of changedFiles) {
729
827
  const completedClaim = completedClaimsByPath.get(file);
730
828
  if (!activeLease) {
731
- if (completedClaim) {
829
+ if (completedClaim || completedScopedTaskOwnsPath(db, completedTasks, file)) {
732
830
  continue;
733
831
  }
734
832
  violations.push({ file, reason_code: 'no_active_lease' });
@@ -838,6 +936,9 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
838
936
  reason_code: classification.reason_code,
839
937
  lease_id: classification.lease?.id ?? null,
840
938
  task_id: classification.lease?.task_id ?? null,
939
+ owner_worktree: classification.owner_claim?.worktree || classification.owner_lease?.worktree || null,
940
+ owner_task_id: classification.owner_claim?.task_id || classification.owner_lease?.task_id || null,
941
+ owner_task_title: classification.owner_claim?.task_title || getTask(db, classification.owner_lease?.task_id || '')?.title || null,
841
942
  enforcement_action: null,
842
943
  quarantine_path: null,
843
944
  };
@@ -853,6 +954,10 @@ export function monitorWorktreesOnce(db, repoRoot, worktrees, options = {}) {
853
954
  details: JSON.stringify({ change_type: change.change_type }),
854
955
  });
855
956
 
957
+ if (classification.status === 'allowed' && classification.lease?.id) {
958
+ touchBoundaryValidationState(db, classification.lease.id, `observed:${change.change_type}`);
959
+ }
960
+
856
961
  if (classification.status === 'denied') {
857
962
  updateWorktreeCompliance(db, worktree.name, COMPLIANCE_STATES.NON_COMPLIANT);
858
963
  if (options.quarantine) {
@@ -894,9 +999,16 @@ export function runCommitGate(db, repoRoot, { cwd = process.cwd(), worktreeName
894
999
  const currentWorktree = worktreeName
895
1000
  ? null
896
1001
  : getCurrentWorktree(repoRoot, cwd);
1002
+ const registeredWorktree = worktreeName
1003
+ ? getWorktree(db, worktreeName)
1004
+ : listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(cwd))
1005
+ || (currentWorktree
1006
+ ? listWorktrees(db).find((entry) => normalizeFsPath(entry.path) === normalizeFsPath(currentWorktree.path))
1007
+ : null)
1008
+ || null;
897
1009
  const resolvedWorktree = worktreeName
898
- ? { name: worktreeName, path: cwd }
899
- : currentWorktree;
1010
+ ? { name: worktreeName, path: registeredWorktree?.path || cwd }
1011
+ : registeredWorktree || currentWorktree;
900
1012
 
901
1013
  if (!resolvedWorktree) {
902
1014
  const result = {
@@ -1,4 +1,5 @@
1
1
  export const DEFAULT_SCAN_IGNORE_PATTERNS = [
2
+ '.mcp.json',
2
3
  'node_modules/**',
3
4
  '.git/**',
4
5
  '.mcp.json',