session-collab-mcp 0.5.0 → 0.5.2

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,216 @@
1
+ // Test helper: In-memory SQLite database for testing
2
+ import Database from 'better-sqlite3';
3
+ import type { DatabaseAdapter, PreparedStatement, QueryResult } from '../sqlite-adapter.js';
4
+
5
+ class TestPreparedStatement implements PreparedStatement {
6
+ private bindings: unknown[] = [];
7
+
8
+ constructor(
9
+ private db: Database.Database,
10
+ private sql: string
11
+ ) {}
12
+
13
+ bind(...values: unknown[]): PreparedStatement {
14
+ this.bindings = values;
15
+ return this;
16
+ }
17
+
18
+ async first<T>(): Promise<T | null> {
19
+ const stmt = this.db.prepare(this.sql);
20
+ const result = stmt.get(...this.bindings) as T | undefined;
21
+ return result ?? null;
22
+ }
23
+
24
+ async all<T>(): Promise<QueryResult<T>> {
25
+ const stmt = this.db.prepare(this.sql);
26
+ const results = stmt.all(...this.bindings) as T[];
27
+ return {
28
+ results,
29
+ meta: { changes: 0, last_row_id: 0 },
30
+ };
31
+ }
32
+
33
+ async run(): Promise<{ meta: { changes: number } }> {
34
+ const stmt = this.db.prepare(this.sql);
35
+ const result = stmt.run(...this.bindings);
36
+ return {
37
+ meta: { changes: result.changes },
38
+ };
39
+ }
40
+
41
+ _run(): Database.RunResult {
42
+ const stmt = this.db.prepare(this.sql);
43
+ return stmt.run(...this.bindings);
44
+ }
45
+ }
46
+
47
+ // Schema statements split for initialization
48
+ const SCHEMA_STATEMENTS = [
49
+ // Sessions table
50
+ `CREATE TABLE IF NOT EXISTS sessions (
51
+ id TEXT PRIMARY KEY,
52
+ name TEXT,
53
+ project_root TEXT NOT NULL,
54
+ machine_id TEXT,
55
+ user_id TEXT,
56
+ created_at TEXT DEFAULT (datetime('now')),
57
+ last_heartbeat TEXT DEFAULT (datetime('now')),
58
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'terminated')),
59
+ current_task TEXT,
60
+ progress TEXT,
61
+ todos TEXT,
62
+ config TEXT
63
+ )`,
64
+ `CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status)`,
65
+ `CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_root)`,
66
+
67
+ // Claims table
68
+ `CREATE TABLE IF NOT EXISTS claims (
69
+ id TEXT PRIMARY KEY,
70
+ session_id TEXT NOT NULL,
71
+ intent TEXT NOT NULL,
72
+ scope TEXT DEFAULT 'medium' CHECK (scope IN ('small', 'medium', 'large')),
73
+ status TEXT DEFAULT 'active' CHECK (status IN ('active', 'completed', 'abandoned')),
74
+ created_at TEXT DEFAULT (datetime('now')),
75
+ updated_at TEXT DEFAULT (datetime('now')),
76
+ completed_summary TEXT,
77
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
78
+ )`,
79
+ `CREATE INDEX IF NOT EXISTS idx_claims_session ON claims(session_id)`,
80
+ `CREATE INDEX IF NOT EXISTS idx_claims_status ON claims(status)`,
81
+
82
+ // Claim files
83
+ `CREATE TABLE IF NOT EXISTS claim_files (
84
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
85
+ claim_id TEXT NOT NULL,
86
+ file_path TEXT NOT NULL,
87
+ is_pattern INTEGER DEFAULT 0,
88
+ FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
89
+ UNIQUE(claim_id, file_path)
90
+ )`,
91
+ `CREATE INDEX IF NOT EXISTS idx_claim_files_path ON claim_files(file_path)`,
92
+ `CREATE INDEX IF NOT EXISTS idx_claim_files_claim ON claim_files(claim_id)`,
93
+
94
+ // Claim symbols
95
+ `CREATE TABLE IF NOT EXISTS claim_symbols (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ claim_id TEXT NOT NULL,
98
+ file_path TEXT NOT NULL,
99
+ symbol_name TEXT NOT NULL,
100
+ symbol_type TEXT DEFAULT 'function' CHECK (symbol_type IN ('function', 'class', 'method', 'variable', 'block', 'other')),
101
+ created_at TEXT DEFAULT (datetime('now')),
102
+ FOREIGN KEY (claim_id) REFERENCES claims(id) ON DELETE CASCADE,
103
+ UNIQUE(claim_id, file_path, symbol_name)
104
+ )`,
105
+ `CREATE INDEX IF NOT EXISTS idx_claim_symbols_path ON claim_symbols(file_path)`,
106
+ `CREATE INDEX IF NOT EXISTS idx_claim_symbols_name ON claim_symbols(symbol_name)`,
107
+ `CREATE INDEX IF NOT EXISTS idx_claim_symbols_claim ON claim_symbols(claim_id)`,
108
+ `CREATE INDEX IF NOT EXISTS idx_claim_symbols_lookup ON claim_symbols(file_path, symbol_name)`,
109
+
110
+ // Messages table
111
+ `CREATE TABLE IF NOT EXISTS messages (
112
+ id TEXT PRIMARY KEY,
113
+ from_session_id TEXT NOT NULL,
114
+ to_session_id TEXT,
115
+ content TEXT NOT NULL,
116
+ read_at TEXT,
117
+ created_at TEXT DEFAULT (datetime('now')),
118
+ FOREIGN KEY (from_session_id) REFERENCES sessions(id) ON DELETE CASCADE
119
+ )`,
120
+ `CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_session_id)`,
121
+ `CREATE INDEX IF NOT EXISTS idx_messages_unread ON messages(to_session_id, read_at)`,
122
+
123
+ // Decisions table
124
+ `CREATE TABLE IF NOT EXISTS decisions (
125
+ id TEXT PRIMARY KEY,
126
+ session_id TEXT NOT NULL,
127
+ category TEXT CHECK (category IN ('architecture', 'naming', 'api', 'database', 'ui', 'other')),
128
+ title TEXT NOT NULL,
129
+ description TEXT NOT NULL,
130
+ created_at TEXT DEFAULT (datetime('now')),
131
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
132
+ )`,
133
+ `CREATE INDEX IF NOT EXISTS idx_decisions_category ON decisions(category)`,
134
+
135
+ // Symbol references
136
+ `CREATE TABLE IF NOT EXISTS symbol_references (
137
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
138
+ source_file TEXT NOT NULL,
139
+ source_symbol TEXT NOT NULL,
140
+ ref_file TEXT NOT NULL,
141
+ ref_line INTEGER,
142
+ ref_context TEXT,
143
+ session_id TEXT NOT NULL,
144
+ created_at TEXT DEFAULT (datetime('now')),
145
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
146
+ UNIQUE(source_file, source_symbol, ref_file, ref_line)
147
+ )`,
148
+ `CREATE INDEX IF NOT EXISTS idx_symbol_refs_source ON symbol_references(source_file, source_symbol)`,
149
+ `CREATE INDEX IF NOT EXISTS idx_symbol_refs_ref_file ON symbol_references(ref_file)`,
150
+ `CREATE INDEX IF NOT EXISTS idx_symbol_refs_session ON symbol_references(session_id)`,
151
+
152
+ // Composite indexes for common query patterns
153
+ `CREATE INDEX IF NOT EXISTS idx_sessions_status_heartbeat ON sessions(status, last_heartbeat)`,
154
+ `CREATE INDEX IF NOT EXISTS idx_claims_status_session ON claims(status, session_id)`,
155
+ `CREATE INDEX IF NOT EXISTS idx_claim_files_path_claim ON claim_files(file_path, claim_id)`,
156
+ ];
157
+
158
+ const CLEANUP_STATEMENTS = [
159
+ 'DELETE FROM symbol_references',
160
+ 'DELETE FROM claim_symbols',
161
+ 'DELETE FROM claim_files',
162
+ 'DELETE FROM claims',
163
+ 'DELETE FROM messages',
164
+ 'DELETE FROM decisions',
165
+ 'DELETE FROM sessions',
166
+ ];
167
+
168
+ export class TestDatabase implements DatabaseAdapter {
169
+ private db: Database.Database;
170
+
171
+ constructor() {
172
+ // Use in-memory database for tests
173
+ this.db = new Database(':memory:');
174
+ this.db.pragma('foreign_keys = ON');
175
+ this.initSchema();
176
+ }
177
+
178
+ private initSchema(): void {
179
+ for (const sql of SCHEMA_STATEMENTS) {
180
+ this.db.prepare(sql).run();
181
+ }
182
+ }
183
+
184
+ prepare(sql: string): PreparedStatement {
185
+ return new TestPreparedStatement(this.db, sql);
186
+ }
187
+
188
+ async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
189
+ const transaction = this.db.transaction(() => {
190
+ return statements.map((stmt) => {
191
+ const testStmt = stmt as TestPreparedStatement;
192
+ const result = testStmt._run();
193
+ return {
194
+ results: [],
195
+ meta: { changes: result.changes, last_row_id: Number(result.lastInsertRowid) },
196
+ };
197
+ });
198
+ });
199
+ return transaction();
200
+ }
201
+
202
+ close(): void {
203
+ this.db.close();
204
+ }
205
+
206
+ // Helper to reset database between tests
207
+ reset(): void {
208
+ for (const sql of CLEANUP_STATEMENTS) {
209
+ this.db.prepare(sql).run();
210
+ }
211
+ }
212
+ }
213
+
214
+ export function createTestDatabase(): TestDatabase {
215
+ return new TestDatabase();
216
+ }
package/src/db/queries.ts CHANGED
@@ -321,18 +321,36 @@ export async function createClaim(
321
321
  }
322
322
 
323
323
  export async function getClaim(db: DatabaseAdapter, id: string): Promise<ClaimWithFiles | null> {
324
- const claim = await db.prepare('SELECT * FROM claims WHERE id = ?').bind(id).first<Claim>();
325
-
326
- if (!claim) return null;
327
-
328
- const files = await db.prepare('SELECT file_path FROM claim_files WHERE claim_id = ?').bind(id).all<{ file_path: string }>();
324
+ // Single query with JOIN to avoid N+1 problem
325
+ const result = await db
326
+ .prepare(
327
+ `SELECT
328
+ c.id, c.session_id, c.intent, c.scope, c.status,
329
+ c.created_at, c.updated_at, c.completed_summary,
330
+ s.name as session_name,
331
+ GROUP_CONCAT(cf.file_path, '|||') as file_paths
332
+ FROM claims c
333
+ LEFT JOIN sessions s ON c.session_id = s.id
334
+ LEFT JOIN claim_files cf ON c.id = cf.claim_id
335
+ WHERE c.id = ?
336
+ GROUP BY c.id`
337
+ )
338
+ .bind(id)
339
+ .first<Claim & { session_name: string | null; file_paths: string | null }>();
329
340
 
330
- const session = await db.prepare('SELECT name FROM sessions WHERE id = ?').bind(claim.session_id).first<{ name: string | null }>();
341
+ if (!result) return null;
331
342
 
332
343
  return {
333
- ...claim,
334
- files: files.results.map((f) => f.file_path),
335
- session_name: session?.name ?? null,
344
+ id: result.id,
345
+ session_id: result.session_id,
346
+ intent: result.intent,
347
+ scope: result.scope,
348
+ status: result.status,
349
+ created_at: result.created_at,
350
+ updated_at: result.updated_at,
351
+ completed_summary: result.completed_summary,
352
+ files: result.file_paths ? result.file_paths.split('|||') : [],
353
+ session_name: result.session_name,
336
354
  };
337
355
  }
338
356
 
@@ -39,8 +39,7 @@ class SqlitePreparedStatement implements PreparedStatement {
39
39
 
40
40
  constructor(
41
41
  private db: Database.Database,
42
- private sql: string,
43
- private onWrite?: () => void
42
+ private sql: string
44
43
  ) {}
45
44
 
46
45
  bind(...values: unknown[]): PreparedStatement {
@@ -66,8 +65,8 @@ class SqlitePreparedStatement implements PreparedStatement {
66
65
  async run(): Promise<{ meta: { changes: number } }> {
67
66
  const stmt = this.db.prepare(this.sql);
68
67
  const result = stmt.run(...this.bindings);
69
- // Trigger checkpoint after write
70
- this.onWrite?.();
68
+ // Note: wal_autocheckpoint handles periodic checkpoints automatically
69
+ // No need to checkpoint after every write - reduces I/O overhead
71
70
  return {
72
71
  meta: { changes: result.changes },
73
72
  };
@@ -104,12 +103,13 @@ class SqliteDatabase implements DatabaseAdapter {
104
103
  }
105
104
 
106
105
  // Force checkpoint to make changes visible to other processes
106
+ // Only called after batch operations, not after individual writes
107
107
  checkpoint(): void {
108
108
  this.db.pragma('wal_checkpoint(PASSIVE)');
109
109
  }
110
110
 
111
111
  prepare(sql: string): PreparedStatement {
112
- return new SqlitePreparedStatement(this.db, sql, () => this.checkpoint());
112
+ return new SqlitePreparedStatement(this.db, sql);
113
113
  }
114
114
 
115
115
  async batch(statements: PreparedStatement[]): Promise<QueryResult<unknown>[]> {
@@ -124,7 +124,7 @@ class SqliteDatabase implements DatabaseAdapter {
124
124
  });
125
125
  });
126
126
  const results = transaction();
127
- // Checkpoint after batch write
127
+ // Checkpoint after batch write to ensure visibility to other processes
128
128
  this.checkpoint();
129
129
  return results;
130
130
  }
@@ -0,0 +1,200 @@
1
+ // Zod schemas for MCP tool input validation
2
+ import { z } from 'zod';
3
+
4
+ // Common schemas
5
+ export const sessionIdSchema = z.string().min(1, 'session_id is required');
6
+ export const claimIdSchema = z.string().min(1, 'claim_id is required');
7
+ export const filePathSchema = z.string().min(1);
8
+ export const filesArraySchema = z.array(filePathSchema).min(1, 'At least one file is required');
9
+
10
+ // Symbol claim schema
11
+ export const symbolClaimSchema = z.object({
12
+ file: z.string().min(1),
13
+ symbols: z.array(z.string().min(1)).min(1),
14
+ symbol_type: z.enum(['function', 'class', 'method', 'variable', 'block', 'other']).optional(),
15
+ });
16
+
17
+ export const symbolClaimsArraySchema = z.array(symbolClaimSchema);
18
+
19
+ // Claim scope schema
20
+ export const claimScopeSchema = z.enum(['small', 'medium', 'large']).default('medium');
21
+
22
+ // Claim status schema
23
+ export const claimStatusSchema = z.enum(['completed', 'abandoned']);
24
+
25
+ // Session tools input schemas
26
+ export const sessionStartSchema = z.object({
27
+ project_root: z.string().min(1, 'project_root is required'),
28
+ name: z.string().optional(),
29
+ machine_id: z.string().optional(),
30
+ });
31
+
32
+ export const sessionEndSchema = z.object({
33
+ session_id: sessionIdSchema,
34
+ release_claims: z.enum(['complete', 'abandon']).default('abandon'),
35
+ });
36
+
37
+ export const sessionListSchema = z.object({
38
+ include_inactive: z.boolean().optional(),
39
+ project_root: z.string().optional(),
40
+ });
41
+
42
+ export const sessionHeartbeatSchema = z.object({
43
+ session_id: sessionIdSchema,
44
+ current_task: z.string().optional(),
45
+ todos: z.array(z.object({
46
+ content: z.string(),
47
+ status: z.enum(['pending', 'in_progress', 'completed']),
48
+ })).optional(),
49
+ });
50
+
51
+ export const statusUpdateSchema = z.object({
52
+ session_id: sessionIdSchema,
53
+ current_task: z.string().optional(),
54
+ todos: z.array(z.object({
55
+ content: z.string(),
56
+ status: z.enum(['pending', 'in_progress', 'completed']),
57
+ })).optional(),
58
+ });
59
+
60
+ export const configSchema = z.object({
61
+ session_id: sessionIdSchema,
62
+ mode: z.enum(['strict', 'smart', 'bypass']).optional(),
63
+ allow_release_others: z.boolean().optional(),
64
+ auto_release_stale: z.boolean().optional(),
65
+ stale_threshold_hours: z.number().min(0).optional(),
66
+ });
67
+
68
+ // Claim tools input schemas
69
+ export const claimCreateSchema = z.object({
70
+ session_id: sessionIdSchema,
71
+ files: z.array(filePathSchema).optional(),
72
+ symbols: symbolClaimsArraySchema.optional(),
73
+ intent: z.string().min(1, 'intent is required'),
74
+ scope: claimScopeSchema.optional(),
75
+ }).refine(
76
+ (data) => (data.files && data.files.length > 0) || (data.symbols && data.symbols.length > 0),
77
+ { message: 'Either files or symbols must be provided' }
78
+ );
79
+
80
+ export const claimCheckSchema = z.object({
81
+ files: filesArraySchema,
82
+ symbols: symbolClaimsArraySchema.optional(),
83
+ session_id: z.string().optional(),
84
+ });
85
+
86
+ export const claimReleaseSchema = z.object({
87
+ session_id: sessionIdSchema,
88
+ claim_id: claimIdSchema,
89
+ status: claimStatusSchema,
90
+ summary: z.string().optional(),
91
+ force: z.boolean().optional(),
92
+ });
93
+
94
+ export const claimListSchema = z.object({
95
+ session_id: z.string().optional(),
96
+ status: z.enum(['active', 'completed', 'abandoned', 'all']).optional(),
97
+ project_root: z.string().optional(),
98
+ });
99
+
100
+ // Message tools input schemas
101
+ export const messageSendSchema = z.object({
102
+ from_session_id: sessionIdSchema,
103
+ to_session_id: z.string().optional(),
104
+ content: z.string().min(1, 'content is required'),
105
+ });
106
+
107
+ export const messageListSchema = z.object({
108
+ session_id: sessionIdSchema,
109
+ unread_only: z.boolean().optional(),
110
+ mark_as_read: z.boolean().optional(),
111
+ });
112
+
113
+ // Decision tools input schemas
114
+ export const decisionAddSchema = z.object({
115
+ session_id: sessionIdSchema,
116
+ category: z.enum(['architecture', 'naming', 'api', 'database', 'ui', 'other']).optional(),
117
+ title: z.string().min(1, 'title is required'),
118
+ description: z.string().min(1, 'description is required'),
119
+ });
120
+
121
+ export const decisionListSchema = z.object({
122
+ category: z.enum(['architecture', 'naming', 'api', 'database', 'ui', 'other']).optional(),
123
+ limit: z.number().min(1).max(100).optional(),
124
+ });
125
+
126
+ // LSP tools input schemas
127
+ // Using z.ZodType to properly type recursive schema
128
+ type LspSymbol = {
129
+ name: string;
130
+ kind: number;
131
+ range?: { start: { line: number; character: number }; end: { line: number; character: number } };
132
+ children?: LspSymbol[];
133
+ };
134
+
135
+ export const lspSymbolSchema: z.ZodType<LspSymbol> = z.object({
136
+ name: z.string(),
137
+ kind: z.number(),
138
+ range: z.object({
139
+ start: z.object({ line: z.number(), character: z.number() }),
140
+ end: z.object({ line: z.number(), character: z.number() }),
141
+ }).optional(),
142
+ children: z.lazy(() => z.array(lspSymbolSchema)).optional(),
143
+ });
144
+
145
+ export const analyzeSymbolsSchema = z.object({
146
+ session_id: sessionIdSchema,
147
+ files: z.array(z.object({
148
+ file: z.string(),
149
+ symbols: z.array(lspSymbolSchema),
150
+ })),
151
+ check_symbols: z.array(z.string()).optional(),
152
+ references: z.array(z.object({
153
+ symbol: z.string(),
154
+ file: z.string(),
155
+ references: z.array(z.object({
156
+ file: z.string(),
157
+ line: z.number(),
158
+ context: z.string().optional(),
159
+ })),
160
+ })).optional(),
161
+ });
162
+
163
+ export const validateSymbolsSchema = z.object({
164
+ file: z.string().min(1),
165
+ symbols: z.array(z.string().min(1)),
166
+ lsp_symbols: z.array(lspSymbolSchema),
167
+ });
168
+
169
+ export const storeReferencesSchema = z.object({
170
+ session_id: sessionIdSchema,
171
+ references: z.array(z.object({
172
+ source_file: z.string(),
173
+ source_symbol: z.string(),
174
+ references: z.array(z.object({
175
+ file: z.string(),
176
+ line: z.number(),
177
+ context: z.string().optional(),
178
+ })),
179
+ })),
180
+ clear_existing: z.boolean().optional(),
181
+ });
182
+
183
+ export const impactAnalysisSchema = z.object({
184
+ session_id: sessionIdSchema,
185
+ file: z.string().min(1),
186
+ symbol: z.string().min(1),
187
+ });
188
+
189
+ // Helper function to validate and return parsed data or error result
190
+ export function validateInput<T>(
191
+ schema: z.ZodSchema<T>,
192
+ data: unknown
193
+ ): { success: true; data: T } | { success: false; error: string } {
194
+ const result = schema.safeParse(data);
195
+ if (result.success) {
196
+ return { success: true, data: result.data };
197
+ }
198
+ const errors = result.error.errors.map((e) => `${e.path.join('.')}: ${e.message}`).join(', ');
199
+ return { success: false, error: errors };
200
+ }
@@ -3,9 +3,10 @@
3
3
  import type { DatabaseAdapter } from '../../db/sqlite-adapter.js';
4
4
  import type { McpTool, McpToolResult } from '../protocol';
5
5
  import { createToolResult } from '../protocol';
6
- import type { ClaimScope, SessionConfig, SymbolClaim } from '../../db/types';
6
+ import type { SessionConfig } from '../../db/types';
7
7
  import { DEFAULT_SESSION_CONFIG } from '../../db/types';
8
8
  import { createClaim, getClaim, listClaims, checkConflicts, releaseClaim, getSession } from '../../db/queries';
9
+ import { claimCreateSchema, claimCheckSchema, claimReleaseSchema, claimListSchema, validateInput } from '../schemas.js';
9
10
 
10
11
  export const claimTools: McpTool[] = [
11
12
  {
@@ -156,37 +157,17 @@ export async function handleClaimTool(
156
157
  ): Promise<McpToolResult> {
157
158
  switch (name) {
158
159
  case 'collab_claim': {
159
- const sessionId = args.session_id as string | undefined;
160
- const files = args.files as string[] | undefined;
161
- const symbols = args.symbols as SymbolClaim[] | undefined;
162
- const intent = args.intent as string | undefined;
163
- const scope = (args.scope as ClaimScope) ?? 'medium';
164
-
165
- // Input validation
166
- if (!sessionId || typeof sessionId !== 'string') {
160
+ // Validate input with Zod schema
161
+ const validation = validateInput(claimCreateSchema, args);
162
+ if (!validation.success) {
167
163
  return createToolResult(
168
- JSON.stringify({ error: 'INVALID_INPUT', message: 'session_id is required' }),
164
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
169
165
  true
170
166
  );
171
167
  }
172
168
 
173
- // Either files or symbols must be provided
174
- const hasFiles = files && Array.isArray(files) && files.length > 0;
175
- const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
176
-
177
- if (!hasFiles && !hasSymbols) {
178
- return createToolResult(
179
- JSON.stringify({ error: 'INVALID_INPUT', message: 'Either files or symbols must be provided' }),
180
- true
181
- );
182
- }
183
-
184
- if (!intent || typeof intent !== 'string' || intent.trim() === '') {
185
- return createToolResult(
186
- JSON.stringify({ error: 'INVALID_INPUT', message: 'intent is required' }),
187
- true
188
- );
189
- }
169
+ const { session_id: sessionId, files, symbols, intent, scope = 'medium' } = validation.data;
170
+ const hasSymbols = symbols && symbols.length > 0;
190
171
 
191
172
  // Verify session exists and is active
192
173
  const session = await getSession(db, sessionId);
@@ -209,10 +190,8 @@ export async function handleClaimTool(
209
190
  }
210
191
  const fileList = Array.from(allFiles);
211
192
 
212
- // Check for conflicts before creating claim
213
- const conflicts = await checkConflicts(db, fileList, sessionId, symbols);
214
-
215
- // Create the claim
193
+ // Create the claim FIRST (atomic operation)
194
+ // This ensures our claim is registered before checking conflicts
216
195
  const { claim } = await createClaim(db, {
217
196
  session_id: sessionId,
218
197
  files: fileList,
@@ -221,6 +200,11 @@ export async function handleClaimTool(
221
200
  symbols: hasSymbols ? symbols : undefined,
222
201
  });
223
202
 
203
+ // Check for conflicts AFTER creating claim
204
+ // This eliminates race condition: if two sessions claim simultaneously,
205
+ // both will see each other's claims and can coordinate
206
+ const conflicts = await checkConflicts(db, fileList, sessionId, symbols);
207
+
224
208
  if (conflicts.length > 0) {
225
209
  // Group conflicts by type (file vs symbol)
226
210
  const fileConflicts = conflicts.filter((c) => c.conflict_level === 'file');
@@ -273,18 +257,17 @@ export async function handleClaimTool(
273
257
  }
274
258
 
275
259
  case 'collab_check': {
276
- const files = args.files as string[] | undefined;
277
- const symbols = args.symbols as SymbolClaim[] | undefined;
278
- const sessionId = args.session_id as string | undefined;
279
-
280
- // Input validation
281
- if (!files || !Array.isArray(files) || files.length === 0) {
260
+ // Validate input with Zod schema
261
+ const validation = validateInput(claimCheckSchema, args);
262
+ if (!validation.success) {
282
263
  return createToolResult(
283
- JSON.stringify({ error: 'INVALID_INPUT', message: 'files array cannot be empty' }),
264
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
284
265
  true
285
266
  );
286
267
  }
287
268
 
269
+ const { files, symbols, session_id: sessionId } = validation.data;
270
+
288
271
  // Check todos status if session_id provided
289
272
  let hasInProgressTodo = false;
290
273
  let todosStatus: { total: number; in_progress: number; completed: number; pending: number } | null = null;
@@ -312,10 +295,6 @@ export async function handleClaimTool(
312
295
  const hasSymbols = symbols && Array.isArray(symbols) && symbols.length > 0;
313
296
  const conflicts = await checkConflicts(db, files, sessionId, hasSymbols ? symbols : undefined);
314
297
 
315
- // Separate file-level and symbol-level conflicts
316
- const fileConflicts = conflicts.filter((c) => c.conflict_level === 'file');
317
- const symbolConflicts = conflicts.filter((c) => c.conflict_level === 'symbol');
318
-
319
298
  // Build per-file status (considering both file and symbol conflicts)
320
299
  const blockedFiles = new Set<string>();
321
300
  const blockedSymbols = new Map<string, Set<string>>(); // file -> symbols
@@ -464,23 +443,17 @@ export async function handleClaimTool(
464
443
  }
465
444
 
466
445
  case 'collab_release': {
467
- const sessionId = args.session_id as string;
468
- const claimId = args.claim_id as string;
469
- const status = args.status as 'completed' | 'abandoned';
470
- const summary = args.summary as string | undefined;
471
- const force = args.force as boolean | undefined;
472
-
473
- // Validate session_id
474
- if (!sessionId || typeof sessionId !== 'string') {
446
+ // Validate input with Zod schema
447
+ const validation = validateInput(claimReleaseSchema, args);
448
+ if (!validation.success) {
475
449
  return createToolResult(
476
- JSON.stringify({
477
- error: 'INVALID_INPUT',
478
- message: 'session_id is required to verify ownership',
479
- }),
450
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
480
451
  true
481
452
  );
482
453
  }
483
454
 
455
+ const { session_id: sessionId, claim_id: claimId, status, summary, force } = validation.data;
456
+
484
457
  const claim = await getClaim(db, claimId);
485
458
  if (!claim) {
486
459
  return createToolResult(
@@ -559,11 +532,17 @@ export async function handleClaimTool(
559
532
  }
560
533
 
561
534
  case 'collab_claims_list': {
562
- const claims = await listClaims(db, {
563
- session_id: args.session_id as string | undefined,
564
- status: (args.status as 'active' | 'completed' | 'abandoned' | 'all') ?? 'active',
565
- project_root: args.project_root as string | undefined,
566
- });
535
+ // Validate input with Zod schema (all fields optional)
536
+ const validation = validateInput(claimListSchema, args);
537
+ if (!validation.success) {
538
+ return createToolResult(
539
+ JSON.stringify({ error: 'INVALID_INPUT', message: validation.error }),
540
+ true
541
+ );
542
+ }
543
+
544
+ const { session_id, status = 'active', project_root } = validation.data;
545
+ const claims = await listClaims(db, { session_id, status, project_root });
567
546
 
568
547
  return createToolResult(
569
548
  JSON.stringify(