agentic-kdd 3.5.7 → 3.5.8

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/CLAUDE.md CHANGED
@@ -38,31 +38,6 @@ Variantes: `aa: aprende`, `aa: aprende — módulo [x]`, `aa: aprende [archivo]`
38
38
 
39
39
  ---
40
40
 
41
- ## CUANDO VES aa: sprint
42
-
43
- Lee `.agentic/agentes/09-sprint.md` y ejecuta su protocolo completo.
44
- Coordina múltiples tareas encadenadas donde el output de cada una alimenta la siguiente.
45
- La memoria KDD fluye entre todas las tareas del sprint.
46
-
47
- Variantes:
48
- - `aa: sprint — [objetivo]` con lista de tareas explícitas
49
- - `aa: sprint — [objetivo]` sin lista → inferir tareas y proponer antes de ejecutar
50
- - `aa: sprint skip` → saltar tarea actual y continuar
51
- - `aa: sprint abort` → cancelar sprint, mantener lo completado
52
-
53
- ---
54
-
55
- ## CUANDO VES aa: aprende
56
-
57
- Lee `.agentic/agentes/08-aprende.md` y ejecuta su protocolo completo.
58
- Analiza el proyecto, detecta patrones/errores/decisiones implícitas
59
- y propone que registrar — siempre pregunta antes de escribir.
60
-
61
- Variantes: aa: aprende / aa: aprende modulo [x] / aa: aprende [archivo]
62
- aa: aprende error: [x] / aa: aprende decision: [x] / aa: aprende patron: [x]
63
-
64
- ---
65
-
66
41
  ## CUANDO VES aa: help
67
42
 
68
43
  Mostrar exactamente esto:
@@ -89,36 +64,6 @@ Consulta del grafo (en terminal):
89
64
  akdd update → actualizar Agentic KDD
90
65
  ```
91
66
 
92
- ## CUANDO VES aa: sprint
93
-
94
- Lee `.agentic/agentes/09-sprint.md` y ejecuta su protocolo completo.
95
- Coordina múltiples tareas encadenadas donde el output de cada una alimenta la siguiente.
96
- La memoria KDD fluye entre todas las tareas del sprint.
97
-
98
- Variantes:
99
- - `aa: sprint — [objetivo]` con lista de tareas explícitas
100
- - `aa: sprint — [objetivo]` sin lista → inferir tareas y proponer antes de ejecutar
101
- - `aa: sprint skip` → saltar tarea actual y continuar
102
- - `aa: sprint abort` → cancelar sprint, mantener lo completado
103
-
104
- ---
105
-
106
- ## CUANDO VES aa: aprende
107
-
108
- Lee `.agentic/agentes/08-aprende.md` y ejecuta su protocolo.
109
- Analiza el código, detecta patrones/errores/decisiones implícitas
110
- y propone qué registrar en memoria KDD antes de escribir nada.
111
-
112
- Variantes:
113
- - `aa: aprende` → analiza todo
114
- - `aa: aprende — módulo [x]` → foco en módulo
115
- - `aa: aprende [archivo]` → foco en archivo
116
- - `aa: aprende — error: [x]` → registrar error directo
117
- - `aa: aprende — decisión: [x]` → registrar decisión directo
118
- - `aa: aprende — patrón: [x]` → registrar patrón directo
119
-
120
- ---
121
-
122
67
  ## CUANDO VES aa:
123
68
 
124
69
  ```
@@ -454,3 +399,83 @@ Luego proceder con el pipeline completo como si el dev hubiera escrito `aa:`.
454
399
  # ============================================================
455
400
  # INSTRUCCIONES DEL PROYECTO — agregar las tuyas aquí abajo
456
401
  # ============================================================
402
+
403
+ ## MODO EXPLORE — aa: explore [objetivo]
404
+
405
+ Antes de implementar, pensar junto al dev sin escribir código.
406
+
407
+ Si el mensaje empieza con `aa: explore` o `aa: think`:
408
+
409
+ 1. Leer `.agentic/config.md` y memoria relevante
410
+ 2. Analizar el objetivo: ¿qué implica? ¿qué riesgos hay? ¿qué alternativas existen?
411
+ 3. Presentar:
412
+ ```
413
+ 🔍 EXPLORE: [objetivo]
414
+
415
+ Opciones de implementación:
416
+ A) [enfoque 1] — pros/contras
417
+ B) [enfoque 2] — pros/contras
418
+
419
+ Contratos en riesgo: [lista]
420
+ Blast radius estimado: [ALTO/MEDIO/BAJO]
421
+ Archivos que tocaría: [lista]
422
+
423
+ ¿Arrancamos con aa: [opción elegida]?
424
+ ```
425
+ 4. NO escribir ningún archivo. NO correr tests. Solo análisis.
426
+
427
+ ---
428
+
429
+ ## CONTRACT GUARD — Registro automático post-TDD Gate
430
+
431
+ Después de cada TDD Gate PASS en cualquier ciclo `aa:`, ejecutar:
432
+
433
+ ```
434
+ node .agentic/grafo/tdd-gate.cjs run [area]
435
+ ```
436
+
437
+ Donde `[area]` es el nombre del módulo implementado (ej: `clients`, `auth`, `invoices`).
438
+
439
+ **Esto es obligatorio.** Sin este paso los contratos no se acumulan.
440
+
441
+ ---
442
+
443
+ # ============================================================
444
+ # INSTRUCCIONES DEL PROYECTO — agregar las tuyas aquí abajo
445
+ # ============================================================
446
+
447
+ ## LOCK MANAGER — Desarrollo multi-instancia
448
+
449
+ Cuando múltiples instancias de Cursor o Claude Code trabajan en el mismo proyecto,
450
+ usar lock-manager.cjs para evitar colisiones.
451
+
452
+ ### Al INICIO de cada ciclo aa:
453
+ ```
454
+ node .agentic/grafo/lock-manager.cjs acquire --module=[área] --files=[archivos] --purpose=[tarea]
455
+ ```
456
+ Si retorna 🔴 → STOP. Otro agente está trabajando en ese módulo. Esperar o elegir otro módulo.
457
+
458
+ ### ANTES de cualquier migration de Prisma o schema:
459
+ ```
460
+ node .agentic/grafo/lock-manager.cjs acquire-schema --purpose=migration
461
+ ```
462
+ Si retorna 🔴 → STOP. No correr migrations hasta que el schema esté libre.
463
+
464
+ ### AL TERMINAR cada ciclo aa: (después de Memory step):
465
+ ```
466
+ node .agentic/grafo/lock-manager.cjs release --module=[área]
467
+ node .agentic/grafo/lock-manager.cjs release-schema # solo si se adquirió
468
+ ```
469
+
470
+ ### Comandos disponibles
471
+ ```
472
+ akdd locks → ver locks activos
473
+ akdd locks release-all → liberar todos los locks de esta instancia
474
+ akdd locks check --files=src/auth.ts,src/middleware.ts → verificar archivos
475
+ ```
476
+
477
+ ### Reglas
478
+ - NUNCA adquirir lock de un módulo que ya tiene otro agente
479
+ - Renovar el lock si la tarea tarda más de 25 minutos: `lock-manager.cjs renew --module=[área]`
480
+ - Si Cursor crashea, los locks expiran solos en 30 minutos
481
+ - Schema lock: máximo 10 minutos — solo para el tiempo de la migration
package/bin/akdd.js CHANGED
@@ -27,6 +27,8 @@ const HELP = `
27
27
  akdd update Update agents + engine (memory stays intact)
28
28
  akdd onboard Analyze existing project + pre-populate memory
29
29
  akdd analyze Cross-artifact consistency check
30
+ akdd locks Lock Manager status
31
+ akdd locks release-all Release all locks for this instance
30
32
  akdd health System health check — what's configured, what's missing
31
33
  akdd health --fix Auto-fix common issues
32
34
 
@@ -160,6 +162,7 @@ switch (command) {
160
162
  case 'update': update(); break;
161
163
  case 'onboard': onboard(); break;
162
164
  case 'analyze': runModule('akdd-analyze.cjs', args[0] || 'run'); break;
165
+ case 'locks': runModule('lock-manager.cjs', args[0] || 'status', args[1] || ''); break;
163
166
  case 'analyze': analyze(); break;
164
167
 
165
168
  // ── v3.0: Health ──────────────────────────────────────────────────────
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+ /**
3
+ * Agentic KDD — Lock Manager v2.0
4
+ * Lock real para desarrollo multi-instancia en el mismo proyecto.
5
+ *
6
+ * v2.0 fixes:
7
+ * - WAL mode en SQLite para escrituras concurrentes reales
8
+ * - Acquire atómico con transacción (elimina TOCTOU race condition)
9
+ * - Paths normalizados a relativos lowercase para evitar false misses
10
+ * - INSTANCE_ID persistido en disco (.agentic/_instance_id) — sobrevive restarts
11
+ * - Deadlock detection antes de cada acquire
12
+ * - Cleanup automático de locks huérfanos al iniciar
13
+ *
14
+ * Uso:
15
+ * node .agentic/grafo/lock-manager.cjs acquire --module=auth --files=src/auth.ts
16
+ * node .agentic/grafo/lock-manager.cjs release --module=auth
17
+ * node .agentic/grafo/lock-manager.cjs status
18
+ * node .agentic/grafo/lock-manager.cjs check --files=src/auth.ts,src/middleware.ts
19
+ * node .agentic/grafo/lock-manager.cjs acquire-schema
20
+ * node .agentic/grafo/lock-manager.cjs release-schema
21
+ * node .agentic/grafo/lock-manager.cjs release-all
22
+ * node .agentic/grafo/lock-manager.cjs wait --module=auth [--timeout=300]
23
+ */
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const crypto = require('crypto');
29
+
30
+ const ROOT = process.cwd();
31
+ const DB_PATH = path.join(ROOT, '.agentic', 'memoria.db');
32
+ const INST_FILE = path.join(ROOT, '.agentic', '_instance_id');
33
+
34
+ const LOCK_TTL_MINUTES = 30;
35
+ const SCHEMA_TTL_MINUTES = 10;
36
+ const WAIT_POLL_MS = 2000;
37
+
38
+ // ── INSTANCE_ID persistido ────────────────────────────────────────────────────
39
+ // Sobrevive reinicios de Cursor — permite liberar locks de sesiones anteriores
40
+
41
+ function getOrCreateInstanceId() {
42
+ try {
43
+ if (fs.existsSync(INST_FILE)) {
44
+ const id = fs.readFileSync(INST_FILE, 'utf8').trim();
45
+ if (id && id.startsWith('inst_')) return id;
46
+ }
47
+ } catch {}
48
+ const id = `inst_${os.hostname().replace(/[^a-zA-Z0-9]/g,'')}_${crypto.randomBytes(6).toString('hex')}`;
49
+ try { fs.writeFileSync(INST_FILE, id, 'utf8'); } catch {}
50
+ return id;
51
+ }
52
+
53
+ const INSTANCE_ID = process.env.AGENTIC_INSTANCE_ID || getOrCreateInstanceId();
54
+
55
+ // ── DB setup ─────────────────────────────────────────────────────────────────
56
+
57
+ function openDB() {
58
+ const projNodeModules = path.join(ROOT, 'node_modules');
59
+ if (!module.paths.includes(projNodeModules)) module.paths.unshift(projNodeModules);
60
+ const db = new (require('better-sqlite3'))(DB_PATH);
61
+ // WAL mode: permite lecturas concurrentes mientras se escribe
62
+ db.pragma('journal_mode = WAL');
63
+ db.pragma('busy_timeout = 5000'); // esperar hasta 5s si la BD está ocupada
64
+ return db;
65
+ }
66
+
67
+ function ensureSchema(db) {
68
+ db.exec(`
69
+ CREATE TABLE IF NOT EXISTS module_locks (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ module_name TEXT NOT NULL UNIQUE,
72
+ instance_id TEXT NOT NULL,
73
+ files TEXT NOT NULL DEFAULT '[]',
74
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
75
+ expires_at TEXT NOT NULL,
76
+ purpose TEXT,
77
+ pid INTEGER
78
+ )
79
+ `);
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS file_locks (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ file_path TEXT NOT NULL UNIQUE,
84
+ module_name TEXT NOT NULL,
85
+ instance_id TEXT NOT NULL,
86
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
87
+ expires_at TEXT NOT NULL
88
+ )
89
+ `);
90
+ db.exec(`
91
+ CREATE TABLE IF NOT EXISTS schema_lock (
92
+ id INTEGER PRIMARY KEY CHECK (id = 1),
93
+ instance_id TEXT NOT NULL,
94
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
95
+ expires_at TEXT NOT NULL,
96
+ purpose TEXT,
97
+ pid INTEGER
98
+ )
99
+ `);
100
+ db.exec(`
101
+ CREATE TABLE IF NOT EXISTS lock_waiters (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ module_name TEXT NOT NULL,
104
+ instance_id TEXT NOT NULL,
105
+ waiting_since TEXT NOT NULL DEFAULT (datetime('now')),
106
+ timeout_at TEXT NOT NULL
107
+ )
108
+ `);
109
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_ml_inst ON module_locks(instance_id)"); } catch {}
110
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_path ON file_locks(file_path)"); } catch {}
111
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_module ON file_locks(module_name)"); } catch {}
112
+ }
113
+
114
+ // ── Helpers ───────────────────────────────────────────────────────────────────
115
+
116
+ function expiresAt(minutes) {
117
+ return new Date(Date.now() + minutes * 60 * 1000).toISOString();
118
+ }
119
+
120
+ function waitTimeoutAt(seconds) {
121
+ return new Date(Date.now() + seconds * 1000).toISOString();
122
+ }
123
+
124
+ // Normaliza paths: absoluto → relativo, backslashes → forward, lowercase en Windows
125
+ function normalizePath(filePath) {
126
+ let p = filePath;
127
+ // Convertir absoluto a relativo si está bajo ROOT
128
+ if (path.isAbsolute(p)) {
129
+ const rel = path.relative(ROOT, p);
130
+ if (!rel.startsWith('..')) p = rel;
131
+ }
132
+ // Forward slashes siempre
133
+ p = p.replace(/\\/g, '/');
134
+ // Quitar ./ inicial
135
+ if (p.startsWith('./')) p = p.slice(2);
136
+ return p;
137
+ }
138
+
139
+ function normalizePaths(files) {
140
+ return [...new Set(files.map(normalizePath))];
141
+ }
142
+
143
+ function purgeExpired(db) {
144
+ db.prepare("DELETE FROM module_locks WHERE expires_at < datetime('now')").run();
145
+ db.prepare("DELETE FROM file_locks WHERE expires_at < datetime('now')").run();
146
+ db.prepare("DELETE FROM schema_lock WHERE expires_at < datetime('now')").run();
147
+ db.prepare("DELETE FROM lock_waiters WHERE timeout_at < datetime('now')").run();
148
+ }
149
+
150
+ // ── Deadlock detection ────────────────────────────────────────────────────────
151
+ // Detecta ciclos: A espera B, B espera A → deadlock
152
+
153
+ function detectDeadlock(db, requestingInstance, targetModule) {
154
+ // ¿Quién tiene targetModule?
155
+ const holder = db.prepare(
156
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
157
+ ).get(targetModule);
158
+ if (!holder) return { deadlock: false };
159
+
160
+ const holderInstance = holder.instance_id;
161
+ if (holderInstance === requestingInstance) return { deadlock: false };
162
+
163
+ // ¿El holder está esperando algún módulo que tenga requestingInstance?
164
+ const holderWaiting = db.prepare(
165
+ "SELECT module_name FROM lock_waiters WHERE instance_id = ?"
166
+ ).all(holderInstance);
167
+
168
+ for (const w of holderWaiting) {
169
+ const wastedModuleHolder = db.prepare(
170
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
171
+ ).get(w.module_name);
172
+ if (wastedModuleHolder && wastedModuleHolder.instance_id === requestingInstance) {
173
+ return {
174
+ deadlock: true,
175
+ cycle: `${requestingInstance} → [${targetModule}] → ${holderInstance} → [${w.module_name}] → ${requestingInstance}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ return { deadlock: false };
181
+ }
182
+
183
+ // ── Acquire module lock (atómico) ─────────────────────────────────────────────
184
+
185
+ function acquireModuleLock(db, moduleName, files = [], purpose = '') {
186
+ const normalFiles = normalizePaths(files);
187
+
188
+ // Transacción atómica — elimina TOCTOU
189
+ const acquire = db.transaction(() => {
190
+ purgeExpired(db);
191
+
192
+ // 1. Verificar si el módulo está bloqueado por otra instancia
193
+ const existingModule = db.prepare(
194
+ "SELECT * FROM module_locks WHERE module_name = ?"
195
+ ).get(moduleName);
196
+
197
+ if (existingModule && existingModule.instance_id !== INSTANCE_ID) {
198
+ // Detectar deadlock antes de reportar bloqueo
199
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
200
+ return {
201
+ success: false,
202
+ reason: `Module "${moduleName}" locked by ${existingModule.instance_id}`,
203
+ locked_by: existingModule.instance_id,
204
+ expires_at: existingModule.expires_at,
205
+ deadlock: dl.deadlock,
206
+ deadlock_cycle: dl.cycle,
207
+ };
208
+ }
209
+
210
+ // 2. Verificar conflictos de archivos con otras instancias
211
+ const fileConflicts = [];
212
+ for (const file of normalFiles) {
213
+ const fileLock = db.prepare(
214
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
215
+ ).get(file, INSTANCE_ID);
216
+ if (fileLock) {
217
+ fileConflicts.push({ file, locked_by_module: fileLock.module_name, locked_by_instance: fileLock.instance_id });
218
+ }
219
+ }
220
+
221
+ if (fileConflicts.length > 0) {
222
+ return { success: false, reason: 'File conflicts', conflicts: fileConflicts };
223
+ }
224
+
225
+ // 3. Adquirir lock de módulo
226
+ const exp = expiresAt(LOCK_TTL_MINUTES);
227
+ db.prepare(`
228
+ INSERT INTO module_locks (module_name, instance_id, files, acquired_at, expires_at, purpose, pid)
229
+ VALUES (?, ?, ?, datetime('now'), ?, ?, ?)
230
+ ON CONFLICT(module_name) DO UPDATE SET
231
+ instance_id=excluded.instance_id, files=excluded.files,
232
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
233
+ purpose=excluded.purpose, pid=excluded.pid
234
+ `).run(moduleName, INSTANCE_ID, JSON.stringify(normalFiles), exp, purpose, process.pid);
235
+
236
+ // 4. Adquirir locks de archivos
237
+ for (const file of normalFiles) {
238
+ db.prepare(`
239
+ INSERT INTO file_locks (file_path, module_name, instance_id, acquired_at, expires_at)
240
+ VALUES (?, ?, ?, datetime('now'), ?)
241
+ ON CONFLICT(file_path) DO UPDATE SET
242
+ module_name=excluded.module_name, instance_id=excluded.instance_id,
243
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
244
+ `).run(file, moduleName, INSTANCE_ID, exp);
245
+ }
246
+
247
+ // 5. Remover de waiters si estaba esperando
248
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?")
249
+ .run(moduleName, INSTANCE_ID);
250
+
251
+ return { success: true, instance_id: INSTANCE_ID, module: moduleName, files: normalFiles, expires_at: exp };
252
+ });
253
+
254
+ return acquire();
255
+ }
256
+
257
+ // ── Release module lock ───────────────────────────────────────────────────────
258
+
259
+ function releaseModuleLock(db, moduleName) {
260
+ const release = db.transaction(() => {
261
+ const lock = db.prepare(
262
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id = ?"
263
+ ).get(moduleName, INSTANCE_ID);
264
+ if (!lock) return { success: false, reason: `No lock owned by this instance for [${moduleName}]` };
265
+
266
+ db.prepare("DELETE FROM module_locks WHERE module_name = ? AND instance_id = ?")
267
+ .run(moduleName, INSTANCE_ID);
268
+ db.prepare("DELETE FROM file_locks WHERE module_name = ? AND instance_id = ?")
269
+ .run(moduleName, INSTANCE_ID);
270
+
271
+ return { success: true, module: moduleName };
272
+ });
273
+ return release();
274
+ }
275
+
276
+ // ── Release all locks for this instance ──────────────────────────────────────
277
+
278
+ function releaseAll(db) {
279
+ const rel = db.transaction(() => {
280
+ const modules = db.prepare("SELECT module_name FROM module_locks WHERE instance_id = ?")
281
+ .all(INSTANCE_ID).map(r => r.module_name);
282
+ db.prepare("DELETE FROM module_locks WHERE instance_id = ?").run(INSTANCE_ID);
283
+ db.prepare("DELETE FROM file_locks WHERE instance_id = ?").run(INSTANCE_ID);
284
+ db.prepare("DELETE FROM schema_lock WHERE instance_id = ?").run(INSTANCE_ID);
285
+ db.prepare("DELETE FROM lock_waiters WHERE instance_id = ?").run(INSTANCE_ID);
286
+ return { success: true, released_modules: modules };
287
+ });
288
+ return rel();
289
+ }
290
+
291
+ // ── Check files ───────────────────────────────────────────────────────────────
292
+
293
+ function checkFiles(db, files) {
294
+ purgeExpired(db);
295
+ const normalFiles = normalizePaths(files);
296
+ const conflicts = [];
297
+ for (const file of normalFiles) {
298
+ const lock = db.prepare(
299
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
300
+ ).get(file, INSTANCE_ID);
301
+ if (lock) conflicts.push({ file, locked_by_module: lock.module_name, locked_by_instance: lock.instance_id, expires_at: lock.expires_at });
302
+ }
303
+ return { safe: conflicts.length === 0, conflicts };
304
+ }
305
+
306
+ // ── Schema lock ───────────────────────────────────────────────────────────────
307
+
308
+ function acquireSchemaLock(db, purpose = 'migration') {
309
+ const acquire = db.transaction(() => {
310
+ purgeExpired(db);
311
+ const existing = db.prepare("SELECT * FROM schema_lock WHERE id = 1").get();
312
+ if (existing && existing.instance_id !== INSTANCE_ID) {
313
+ return { success: false, reason: `Schema locked by ${existing.instance_id} for ${existing.purpose}`, expires_at: existing.expires_at };
314
+ }
315
+ const exp = expiresAt(SCHEMA_TTL_MINUTES);
316
+ db.prepare(`
317
+ INSERT INTO schema_lock (id, instance_id, acquired_at, expires_at, purpose, pid)
318
+ VALUES (1, ?, datetime('now'), ?, ?, ?)
319
+ ON CONFLICT(id) DO UPDATE SET instance_id=excluded.instance_id,
320
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
321
+ purpose=excluded.purpose, pid=excluded.pid
322
+ `).run(INSTANCE_ID, exp, purpose, process.pid);
323
+ return { success: true, instance_id: INSTANCE_ID, purpose, expires_at: exp };
324
+ });
325
+ return acquire();
326
+ }
327
+
328
+ function releaseSchemaLock(db) {
329
+ const result = db.prepare("DELETE FROM schema_lock WHERE id = 1 AND instance_id = ?").run(INSTANCE_ID);
330
+ return { success: result.changes > 0 };
331
+ }
332
+
333
+ // ── Wait for lock ─────────────────────────────────────────────────────────────
334
+
335
+ function waitForLock(db, moduleName, timeoutSeconds = 300) {
336
+ const deadline = Date.now() + timeoutSeconds * 1000;
337
+
338
+ // Register as waiter
339
+ db.prepare(`
340
+ INSERT OR REPLACE INTO lock_waiters (module_name, instance_id, waiting_since, timeout_at)
341
+ VALUES (?, ?, datetime('now'), ?)
342
+ `).run(moduleName, INSTANCE_ID, waitTimeoutAt(timeoutSeconds));
343
+
344
+ console.log(`⏳ Waiting for [${moduleName}] to be released (timeout: ${timeoutSeconds}s)...`);
345
+
346
+ while (Date.now() < deadline) {
347
+ const existing = db.prepare(
348
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id != ?"
349
+ ).get(moduleName, INSTANCE_ID);
350
+
351
+ if (!existing || new Date(existing.expires_at) < new Date()) {
352
+ // Lock is free — try to acquire
353
+ const result = acquireModuleLock(db, moduleName, [], 'waited');
354
+ if (result.success) {
355
+ console.log(`✅ Lock acquired after waiting: [${moduleName}]`);
356
+ return result;
357
+ }
358
+ }
359
+
360
+ // Check for deadlock while waiting
361
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
362
+ if (dl.deadlock) {
363
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
364
+ return { success: false, reason: 'Deadlock detected', deadlock: true, cycle: dl.deadlock_cycle };
365
+ }
366
+
367
+ // Sleep poll
368
+ const start = Date.now();
369
+ while (Date.now() - start < WAIT_POLL_MS) { /* spin */ }
370
+ }
371
+
372
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
373
+ return { success: false, reason: `Timeout after ${timeoutSeconds}s waiting for [${moduleName}]` };
374
+ }
375
+
376
+ // ── Renew lock ────────────────────────────────────────────────────────────────
377
+
378
+ function renewLock(db, moduleName) {
379
+ const exp = expiresAt(LOCK_TTL_MINUTES);
380
+ const r1 = db.prepare("UPDATE module_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
381
+ .run(exp, moduleName, INSTANCE_ID);
382
+ db.prepare("UPDATE file_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
383
+ .run(exp, moduleName, INSTANCE_ID);
384
+ return { success: r1.changes > 0, renewed_until: exp };
385
+ }
386
+
387
+ // ── Status ────────────────────────────────────────────────────────────────────
388
+
389
+ function getStatus(db) {
390
+ purgeExpired(db);
391
+ return {
392
+ instance_id: INSTANCE_ID,
393
+ module_locks: db.prepare("SELECT * FROM module_locks ORDER BY acquired_at DESC").all(),
394
+ schema_lock: db.prepare("SELECT * FROM schema_lock WHERE id=1").get() || null,
395
+ waiters: db.prepare("SELECT * FROM lock_waiters").all(),
396
+ };
397
+ }
398
+
399
+ function printStatus(s) {
400
+ console.log('\n══════════════════════════════════════════════════');
401
+ console.log(' 🔒 Lock Manager v2.0 — Status');
402
+ console.log(` This instance: ${s.instance_id}`);
403
+ console.log('══════════════════════════════════════════════════');
404
+
405
+ if (s.schema_lock) {
406
+ const mine = s.schema_lock.instance_id === s.instance_id;
407
+ console.log(`\n 📐 Schema: ${mine ? '✅ YOURS' : '🔴 BLOCKED by ' + s.schema_lock.instance_id}`);
408
+ console.log(` Purpose: ${s.schema_lock.purpose} | Expires: ${s.schema_lock.expires_at}`);
409
+ } else {
410
+ console.log('\n 📐 Schema: ✅ free');
411
+ }
412
+
413
+ if (s.module_locks.length === 0) {
414
+ console.log('\n 📦 No active module locks\n');
415
+ } else {
416
+ console.log(`\n 📦 Module locks (${s.module_locks.length}):`);
417
+ for (const lock of s.module_locks) {
418
+ const mine = lock.instance_id === s.instance_id;
419
+ const files = JSON.parse(lock.files || '[]');
420
+ console.log(`\n ${mine ? '✅' : '🔴'} [${lock.module_name}] — ${mine ? 'YOURS' : 'BLOCKED'}`);
421
+ console.log(` Instance: ${lock.instance_id}`);
422
+ if (files.length) console.log(` Files: ${files.join(', ')}`);
423
+ if (lock.purpose) console.log(` Purpose: ${lock.purpose}`);
424
+ console.log(` Expires: ${lock.expires_at}`);
425
+ }
426
+ }
427
+
428
+ if (s.waiters.length > 0) {
429
+ console.log(`\n ⏳ Waiting (${s.waiters.length}):`);
430
+ for (const w of s.waiters) {
431
+ console.log(` [${w.module_name}] ← ${w.instance_id} (since ${w.waiting_since})`);
432
+ }
433
+ }
434
+
435
+ console.log('\n══════════════════════════════════════════════════\n');
436
+ }
437
+
438
+ // ── CLI ───────────────────────────────────────────────────────────────────────
439
+
440
+ if (require.main === module) {
441
+ const args = process.argv.slice(2);
442
+ const cmd = args[0];
443
+ const opts = {};
444
+ for (const arg of args.slice(1)) {
445
+ const m = arg.match(/^--?([\w-]+)(?:=(.+))?$/);
446
+ if (m) opts[m[1]] = m[2] !== undefined ? m[2] : true;
447
+ }
448
+
449
+ if (!fs.existsSync(DB_PATH)) {
450
+ console.error('Lock Manager: memoria.db not found. Run akdd init first.');
451
+ process.exit(1);
452
+ }
453
+
454
+ let db;
455
+ try { db = openDB(); ensureSchema(db); }
456
+ catch(e) { console.error('Lock Manager DB error:', e.message); process.exit(1); }
457
+
458
+ switch(cmd) {
459
+ case 'acquire': {
460
+ const mod = opts.module || opts.m;
461
+ if (!mod) { console.error('--module required'); process.exit(1); }
462
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
463
+ const purpose = opts.purpose || opts.p || '';
464
+ const result = acquireModuleLock(db, mod, files, purpose);
465
+ if (result.success) {
466
+ console.log(`✅ Lock acquired: [${mod}]`);
467
+ if (files.length) console.log(` Files: ${result.files.join(', ')}`);
468
+ console.log(` Expires: ${result.expires_at}`);
469
+ } else {
470
+ console.error(`🔴 DENIED: ${result.reason}`);
471
+ if (result.deadlock) console.error(` 💀 DEADLOCK: ${result.deadlock_cycle}`);
472
+ if (result.conflicts) result.conflicts.forEach(c => console.error(` Conflict: ${c.file} → [${c.locked_by_module}]`));
473
+ process.exit(1);
474
+ }
475
+ break;
476
+ }
477
+ case 'release': {
478
+ const mod = opts.module || opts.m;
479
+ if (!mod) { console.error('--module required'); process.exit(1); }
480
+ const r = releaseModuleLock(db, mod);
481
+ if (r.success) console.log(`✅ Released: [${mod}]`);
482
+ else { console.error(`🔴 ${r.reason}`); process.exit(1); }
483
+ break;
484
+ }
485
+ case 'release-all': {
486
+ const r = releaseAll(db);
487
+ console.log(`✅ Released ${r.released_modules.length} locks: ${r.released_modules.join(', ') || '(none)'}`);
488
+ break;
489
+ }
490
+ case 'check': {
491
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
492
+ if (!files.length) { console.error('--files required'); process.exit(1); }
493
+ const r = checkFiles(db, files);
494
+ if (r.safe) { console.log('✅ Files free — safe to proceed'); }
495
+ else {
496
+ console.error('🔴 Conflicts:');
497
+ r.conflicts.forEach(c => console.error(` ${c.file} → [${c.locked_by_module}]`));
498
+ process.exit(1);
499
+ }
500
+ break;
501
+ }
502
+ case 'acquire-schema': {
503
+ const r = acquireSchemaLock(db, opts.purpose || opts.p || 'migration');
504
+ if (r.success) console.log(`✅ Schema lock acquired — ${r.purpose} | Expires: ${r.expires_at}`);
505
+ else { console.error(`🔴 Schema locked: ${r.reason}`); process.exit(1); }
506
+ break;
507
+ }
508
+ case 'release-schema': {
509
+ const r = releaseSchemaLock(db);
510
+ if (r.success) console.log('✅ Schema lock released');
511
+ else console.error('⚠️ No schema lock owned by this instance');
512
+ break;
513
+ }
514
+ case 'renew': {
515
+ const mod = opts.module || opts.m;
516
+ if (!mod) { console.error('--module required'); process.exit(1); }
517
+ const r = renewLock(db, mod);
518
+ if (r.success) console.log(`✅ Renewed [${mod}] until ${r.renewed_until}`);
519
+ else { console.error('🔴 No lock to renew'); process.exit(1); }
520
+ break;
521
+ }
522
+ case 'wait': {
523
+ const mod = opts.module || opts.m;
524
+ if (!mod) { console.error('--module required'); process.exit(1); }
525
+ const timeout = parseInt(opts.timeout || '300');
526
+ const r = waitForLock(db, mod, timeout);
527
+ if (!r.success) { console.error(`🔴 ${r.reason}`); process.exit(1); }
528
+ break;
529
+ }
530
+ case 'status':
531
+ default: {
532
+ printStatus(getStatus(db));
533
+ break;
534
+ }
535
+ }
536
+ db.close();
537
+ }
538
+
539
+ module.exports = {
540
+ acquireModuleLock, releaseModuleLock, releaseAll,
541
+ acquireSchemaLock, releaseSchemaLock,
542
+ checkFiles, renewLock, waitForLock,
543
+ getStatus, detectDeadlock, normalizePath,
544
+ INSTANCE_ID,
545
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-kdd",
3
- "version": "3.5.7",
3
+ "version": "3.5.8",
4
4
  "description": "Autonomous development pipeline — aa: · ag: · audit: · AST graph · Harness · Specs · Impact analysis · Decision trail · Metrics · MCP server. Works with Cursor and Claude Code.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+ /**
3
+ * Agentic KDD — Lock Manager v2.0
4
+ * Lock real para desarrollo multi-instancia en el mismo proyecto.
5
+ *
6
+ * v2.0 fixes:
7
+ * - WAL mode en SQLite para escrituras concurrentes reales
8
+ * - Acquire atómico con transacción (elimina TOCTOU race condition)
9
+ * - Paths normalizados a relativos lowercase para evitar false misses
10
+ * - INSTANCE_ID persistido en disco (.agentic/_instance_id) — sobrevive restarts
11
+ * - Deadlock detection antes de cada acquire
12
+ * - Cleanup automático de locks huérfanos al iniciar
13
+ *
14
+ * Uso:
15
+ * node .agentic/grafo/lock-manager.cjs acquire --module=auth --files=src/auth.ts
16
+ * node .agentic/grafo/lock-manager.cjs release --module=auth
17
+ * node .agentic/grafo/lock-manager.cjs status
18
+ * node .agentic/grafo/lock-manager.cjs check --files=src/auth.ts,src/middleware.ts
19
+ * node .agentic/grafo/lock-manager.cjs acquire-schema
20
+ * node .agentic/grafo/lock-manager.cjs release-schema
21
+ * node .agentic/grafo/lock-manager.cjs release-all
22
+ * node .agentic/grafo/lock-manager.cjs wait --module=auth [--timeout=300]
23
+ */
24
+
25
+ const path = require('path');
26
+ const fs = require('fs');
27
+ const os = require('os');
28
+ const crypto = require('crypto');
29
+
30
+ const ROOT = process.cwd();
31
+ const DB_PATH = path.join(ROOT, '.agentic', 'memoria.db');
32
+ const INST_FILE = path.join(ROOT, '.agentic', '_instance_id');
33
+
34
+ const LOCK_TTL_MINUTES = 30;
35
+ const SCHEMA_TTL_MINUTES = 10;
36
+ const WAIT_POLL_MS = 2000;
37
+
38
+ // ── INSTANCE_ID persistido ────────────────────────────────────────────────────
39
+ // Sobrevive reinicios de Cursor — permite liberar locks de sesiones anteriores
40
+
41
+ function getOrCreateInstanceId() {
42
+ try {
43
+ if (fs.existsSync(INST_FILE)) {
44
+ const id = fs.readFileSync(INST_FILE, 'utf8').trim();
45
+ if (id && id.startsWith('inst_')) return id;
46
+ }
47
+ } catch {}
48
+ const id = `inst_${os.hostname().replace(/[^a-zA-Z0-9]/g,'')}_${crypto.randomBytes(6).toString('hex')}`;
49
+ try { fs.writeFileSync(INST_FILE, id, 'utf8'); } catch {}
50
+ return id;
51
+ }
52
+
53
+ const INSTANCE_ID = process.env.AGENTIC_INSTANCE_ID || getOrCreateInstanceId();
54
+
55
+ // ── DB setup ─────────────────────────────────────────────────────────────────
56
+
57
+ function openDB() {
58
+ const projNodeModules = path.join(ROOT, 'node_modules');
59
+ if (!module.paths.includes(projNodeModules)) module.paths.unshift(projNodeModules);
60
+ const db = new (require('better-sqlite3'))(DB_PATH);
61
+ // WAL mode: permite lecturas concurrentes mientras se escribe
62
+ db.pragma('journal_mode = WAL');
63
+ db.pragma('busy_timeout = 5000'); // esperar hasta 5s si la BD está ocupada
64
+ return db;
65
+ }
66
+
67
+ function ensureSchema(db) {
68
+ db.exec(`
69
+ CREATE TABLE IF NOT EXISTS module_locks (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ module_name TEXT NOT NULL UNIQUE,
72
+ instance_id TEXT NOT NULL,
73
+ files TEXT NOT NULL DEFAULT '[]',
74
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
75
+ expires_at TEXT NOT NULL,
76
+ purpose TEXT,
77
+ pid INTEGER
78
+ )
79
+ `);
80
+ db.exec(`
81
+ CREATE TABLE IF NOT EXISTS file_locks (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ file_path TEXT NOT NULL UNIQUE,
84
+ module_name TEXT NOT NULL,
85
+ instance_id TEXT NOT NULL,
86
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
87
+ expires_at TEXT NOT NULL
88
+ )
89
+ `);
90
+ db.exec(`
91
+ CREATE TABLE IF NOT EXISTS schema_lock (
92
+ id INTEGER PRIMARY KEY CHECK (id = 1),
93
+ instance_id TEXT NOT NULL,
94
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
95
+ expires_at TEXT NOT NULL,
96
+ purpose TEXT,
97
+ pid INTEGER
98
+ )
99
+ `);
100
+ db.exec(`
101
+ CREATE TABLE IF NOT EXISTS lock_waiters (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ module_name TEXT NOT NULL,
104
+ instance_id TEXT NOT NULL,
105
+ waiting_since TEXT NOT NULL DEFAULT (datetime('now')),
106
+ timeout_at TEXT NOT NULL
107
+ )
108
+ `);
109
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_ml_inst ON module_locks(instance_id)"); } catch {}
110
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_path ON file_locks(file_path)"); } catch {}
111
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_fl_module ON file_locks(module_name)"); } catch {}
112
+ }
113
+
114
+ // ── Helpers ───────────────────────────────────────────────────────────────────
115
+
116
+ function expiresAt(minutes) {
117
+ return new Date(Date.now() + minutes * 60 * 1000).toISOString();
118
+ }
119
+
120
+ function waitTimeoutAt(seconds) {
121
+ return new Date(Date.now() + seconds * 1000).toISOString();
122
+ }
123
+
124
+ // Normaliza paths: absoluto → relativo, backslashes → forward, lowercase en Windows
125
+ function normalizePath(filePath) {
126
+ let p = filePath;
127
+ // Convertir absoluto a relativo si está bajo ROOT
128
+ if (path.isAbsolute(p)) {
129
+ const rel = path.relative(ROOT, p);
130
+ if (!rel.startsWith('..')) p = rel;
131
+ }
132
+ // Forward slashes siempre
133
+ p = p.replace(/\\/g, '/');
134
+ // Quitar ./ inicial
135
+ if (p.startsWith('./')) p = p.slice(2);
136
+ return p;
137
+ }
138
+
139
+ function normalizePaths(files) {
140
+ return [...new Set(files.map(normalizePath))];
141
+ }
142
+
143
+ function purgeExpired(db) {
144
+ db.prepare("DELETE FROM module_locks WHERE expires_at < datetime('now')").run();
145
+ db.prepare("DELETE FROM file_locks WHERE expires_at < datetime('now')").run();
146
+ db.prepare("DELETE FROM schema_lock WHERE expires_at < datetime('now')").run();
147
+ db.prepare("DELETE FROM lock_waiters WHERE timeout_at < datetime('now')").run();
148
+ }
149
+
150
+ // ── Deadlock detection ────────────────────────────────────────────────────────
151
+ // Detecta ciclos: A espera B, B espera A → deadlock
152
+
153
+ function detectDeadlock(db, requestingInstance, targetModule) {
154
+ // ¿Quién tiene targetModule?
155
+ const holder = db.prepare(
156
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
157
+ ).get(targetModule);
158
+ if (!holder) return { deadlock: false };
159
+
160
+ const holderInstance = holder.instance_id;
161
+ if (holderInstance === requestingInstance) return { deadlock: false };
162
+
163
+ // ¿El holder está esperando algún módulo que tenga requestingInstance?
164
+ const holderWaiting = db.prepare(
165
+ "SELECT module_name FROM lock_waiters WHERE instance_id = ?"
166
+ ).all(holderInstance);
167
+
168
+ for (const w of holderWaiting) {
169
+ const wastedModuleHolder = db.prepare(
170
+ "SELECT instance_id FROM module_locks WHERE module_name = ?"
171
+ ).get(w.module_name);
172
+ if (wastedModuleHolder && wastedModuleHolder.instance_id === requestingInstance) {
173
+ return {
174
+ deadlock: true,
175
+ cycle: `${requestingInstance} → [${targetModule}] → ${holderInstance} → [${w.module_name}] → ${requestingInstance}`,
176
+ };
177
+ }
178
+ }
179
+
180
+ return { deadlock: false };
181
+ }
182
+
183
+ // ── Acquire module lock (atómico) ─────────────────────────────────────────────
184
+
185
+ function acquireModuleLock(db, moduleName, files = [], purpose = '') {
186
+ const normalFiles = normalizePaths(files);
187
+
188
+ // Transacción atómica — elimina TOCTOU
189
+ const acquire = db.transaction(() => {
190
+ purgeExpired(db);
191
+
192
+ // 1. Verificar si el módulo está bloqueado por otra instancia
193
+ const existingModule = db.prepare(
194
+ "SELECT * FROM module_locks WHERE module_name = ?"
195
+ ).get(moduleName);
196
+
197
+ if (existingModule && existingModule.instance_id !== INSTANCE_ID) {
198
+ // Detectar deadlock antes de reportar bloqueo
199
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
200
+ return {
201
+ success: false,
202
+ reason: `Module "${moduleName}" locked by ${existingModule.instance_id}`,
203
+ locked_by: existingModule.instance_id,
204
+ expires_at: existingModule.expires_at,
205
+ deadlock: dl.deadlock,
206
+ deadlock_cycle: dl.cycle,
207
+ };
208
+ }
209
+
210
+ // 2. Verificar conflictos de archivos con otras instancias
211
+ const fileConflicts = [];
212
+ for (const file of normalFiles) {
213
+ const fileLock = db.prepare(
214
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
215
+ ).get(file, INSTANCE_ID);
216
+ if (fileLock) {
217
+ fileConflicts.push({ file, locked_by_module: fileLock.module_name, locked_by_instance: fileLock.instance_id });
218
+ }
219
+ }
220
+
221
+ if (fileConflicts.length > 0) {
222
+ return { success: false, reason: 'File conflicts', conflicts: fileConflicts };
223
+ }
224
+
225
+ // 3. Adquirir lock de módulo
226
+ const exp = expiresAt(LOCK_TTL_MINUTES);
227
+ db.prepare(`
228
+ INSERT INTO module_locks (module_name, instance_id, files, acquired_at, expires_at, purpose, pid)
229
+ VALUES (?, ?, ?, datetime('now'), ?, ?, ?)
230
+ ON CONFLICT(module_name) DO UPDATE SET
231
+ instance_id=excluded.instance_id, files=excluded.files,
232
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
233
+ purpose=excluded.purpose, pid=excluded.pid
234
+ `).run(moduleName, INSTANCE_ID, JSON.stringify(normalFiles), exp, purpose, process.pid);
235
+
236
+ // 4. Adquirir locks de archivos
237
+ for (const file of normalFiles) {
238
+ db.prepare(`
239
+ INSERT INTO file_locks (file_path, module_name, instance_id, acquired_at, expires_at)
240
+ VALUES (?, ?, ?, datetime('now'), ?)
241
+ ON CONFLICT(file_path) DO UPDATE SET
242
+ module_name=excluded.module_name, instance_id=excluded.instance_id,
243
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at
244
+ `).run(file, moduleName, INSTANCE_ID, exp);
245
+ }
246
+
247
+ // 5. Remover de waiters si estaba esperando
248
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?")
249
+ .run(moduleName, INSTANCE_ID);
250
+
251
+ return { success: true, instance_id: INSTANCE_ID, module: moduleName, files: normalFiles, expires_at: exp };
252
+ });
253
+
254
+ return acquire();
255
+ }
256
+
257
+ // ── Release module lock ───────────────────────────────────────────────────────
258
+
259
+ function releaseModuleLock(db, moduleName) {
260
+ const release = db.transaction(() => {
261
+ const lock = db.prepare(
262
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id = ?"
263
+ ).get(moduleName, INSTANCE_ID);
264
+ if (!lock) return { success: false, reason: `No lock owned by this instance for [${moduleName}]` };
265
+
266
+ db.prepare("DELETE FROM module_locks WHERE module_name = ? AND instance_id = ?")
267
+ .run(moduleName, INSTANCE_ID);
268
+ db.prepare("DELETE FROM file_locks WHERE module_name = ? AND instance_id = ?")
269
+ .run(moduleName, INSTANCE_ID);
270
+
271
+ return { success: true, module: moduleName };
272
+ });
273
+ return release();
274
+ }
275
+
276
+ // ── Release all locks for this instance ──────────────────────────────────────
277
+
278
+ function releaseAll(db) {
279
+ const rel = db.transaction(() => {
280
+ const modules = db.prepare("SELECT module_name FROM module_locks WHERE instance_id = ?")
281
+ .all(INSTANCE_ID).map(r => r.module_name);
282
+ db.prepare("DELETE FROM module_locks WHERE instance_id = ?").run(INSTANCE_ID);
283
+ db.prepare("DELETE FROM file_locks WHERE instance_id = ?").run(INSTANCE_ID);
284
+ db.prepare("DELETE FROM schema_lock WHERE instance_id = ?").run(INSTANCE_ID);
285
+ db.prepare("DELETE FROM lock_waiters WHERE instance_id = ?").run(INSTANCE_ID);
286
+ return { success: true, released_modules: modules };
287
+ });
288
+ return rel();
289
+ }
290
+
291
+ // ── Check files ───────────────────────────────────────────────────────────────
292
+
293
+ function checkFiles(db, files) {
294
+ purgeExpired(db);
295
+ const normalFiles = normalizePaths(files);
296
+ const conflicts = [];
297
+ for (const file of normalFiles) {
298
+ const lock = db.prepare(
299
+ "SELECT * FROM file_locks WHERE file_path = ? AND instance_id != ?"
300
+ ).get(file, INSTANCE_ID);
301
+ if (lock) conflicts.push({ file, locked_by_module: lock.module_name, locked_by_instance: lock.instance_id, expires_at: lock.expires_at });
302
+ }
303
+ return { safe: conflicts.length === 0, conflicts };
304
+ }
305
+
306
+ // ── Schema lock ───────────────────────────────────────────────────────────────
307
+
308
+ function acquireSchemaLock(db, purpose = 'migration') {
309
+ const acquire = db.transaction(() => {
310
+ purgeExpired(db);
311
+ const existing = db.prepare("SELECT * FROM schema_lock WHERE id = 1").get();
312
+ if (existing && existing.instance_id !== INSTANCE_ID) {
313
+ return { success: false, reason: `Schema locked by ${existing.instance_id} for ${existing.purpose}`, expires_at: existing.expires_at };
314
+ }
315
+ const exp = expiresAt(SCHEMA_TTL_MINUTES);
316
+ db.prepare(`
317
+ INSERT INTO schema_lock (id, instance_id, acquired_at, expires_at, purpose, pid)
318
+ VALUES (1, ?, datetime('now'), ?, ?, ?)
319
+ ON CONFLICT(id) DO UPDATE SET instance_id=excluded.instance_id,
320
+ acquired_at=excluded.acquired_at, expires_at=excluded.expires_at,
321
+ purpose=excluded.purpose, pid=excluded.pid
322
+ `).run(INSTANCE_ID, exp, purpose, process.pid);
323
+ return { success: true, instance_id: INSTANCE_ID, purpose, expires_at: exp };
324
+ });
325
+ return acquire();
326
+ }
327
+
328
+ function releaseSchemaLock(db) {
329
+ const result = db.prepare("DELETE FROM schema_lock WHERE id = 1 AND instance_id = ?").run(INSTANCE_ID);
330
+ return { success: result.changes > 0 };
331
+ }
332
+
333
+ // ── Wait for lock ─────────────────────────────────────────────────────────────
334
+
335
+ function waitForLock(db, moduleName, timeoutSeconds = 300) {
336
+ const deadline = Date.now() + timeoutSeconds * 1000;
337
+
338
+ // Register as waiter
339
+ db.prepare(`
340
+ INSERT OR REPLACE INTO lock_waiters (module_name, instance_id, waiting_since, timeout_at)
341
+ VALUES (?, ?, datetime('now'), ?)
342
+ `).run(moduleName, INSTANCE_ID, waitTimeoutAt(timeoutSeconds));
343
+
344
+ console.log(`⏳ Waiting for [${moduleName}] to be released (timeout: ${timeoutSeconds}s)...`);
345
+
346
+ while (Date.now() < deadline) {
347
+ const existing = db.prepare(
348
+ "SELECT * FROM module_locks WHERE module_name = ? AND instance_id != ?"
349
+ ).get(moduleName, INSTANCE_ID);
350
+
351
+ if (!existing || new Date(existing.expires_at) < new Date()) {
352
+ // Lock is free — try to acquire
353
+ const result = acquireModuleLock(db, moduleName, [], 'waited');
354
+ if (result.success) {
355
+ console.log(`✅ Lock acquired after waiting: [${moduleName}]`);
356
+ return result;
357
+ }
358
+ }
359
+
360
+ // Check for deadlock while waiting
361
+ const dl = detectDeadlock(db, INSTANCE_ID, moduleName);
362
+ if (dl.deadlock) {
363
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
364
+ return { success: false, reason: 'Deadlock detected', deadlock: true, cycle: dl.deadlock_cycle };
365
+ }
366
+
367
+ // Sleep poll
368
+ const start = Date.now();
369
+ while (Date.now() - start < WAIT_POLL_MS) { /* spin */ }
370
+ }
371
+
372
+ db.prepare("DELETE FROM lock_waiters WHERE module_name = ? AND instance_id = ?").run(moduleName, INSTANCE_ID);
373
+ return { success: false, reason: `Timeout after ${timeoutSeconds}s waiting for [${moduleName}]` };
374
+ }
375
+
376
+ // ── Renew lock ────────────────────────────────────────────────────────────────
377
+
378
+ function renewLock(db, moduleName) {
379
+ const exp = expiresAt(LOCK_TTL_MINUTES);
380
+ const r1 = db.prepare("UPDATE module_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
381
+ .run(exp, moduleName, INSTANCE_ID);
382
+ db.prepare("UPDATE file_locks SET expires_at=? WHERE module_name=? AND instance_id=?")
383
+ .run(exp, moduleName, INSTANCE_ID);
384
+ return { success: r1.changes > 0, renewed_until: exp };
385
+ }
386
+
387
+ // ── Status ────────────────────────────────────────────────────────────────────
388
+
389
+ function getStatus(db) {
390
+ purgeExpired(db);
391
+ return {
392
+ instance_id: INSTANCE_ID,
393
+ module_locks: db.prepare("SELECT * FROM module_locks ORDER BY acquired_at DESC").all(),
394
+ schema_lock: db.prepare("SELECT * FROM schema_lock WHERE id=1").get() || null,
395
+ waiters: db.prepare("SELECT * FROM lock_waiters").all(),
396
+ };
397
+ }
398
+
399
+ function printStatus(s) {
400
+ console.log('\n══════════════════════════════════════════════════');
401
+ console.log(' 🔒 Lock Manager v2.0 — Status');
402
+ console.log(` This instance: ${s.instance_id}`);
403
+ console.log('══════════════════════════════════════════════════');
404
+
405
+ if (s.schema_lock) {
406
+ const mine = s.schema_lock.instance_id === s.instance_id;
407
+ console.log(`\n 📐 Schema: ${mine ? '✅ YOURS' : '🔴 BLOCKED by ' + s.schema_lock.instance_id}`);
408
+ console.log(` Purpose: ${s.schema_lock.purpose} | Expires: ${s.schema_lock.expires_at}`);
409
+ } else {
410
+ console.log('\n 📐 Schema: ✅ free');
411
+ }
412
+
413
+ if (s.module_locks.length === 0) {
414
+ console.log('\n 📦 No active module locks\n');
415
+ } else {
416
+ console.log(`\n 📦 Module locks (${s.module_locks.length}):`);
417
+ for (const lock of s.module_locks) {
418
+ const mine = lock.instance_id === s.instance_id;
419
+ const files = JSON.parse(lock.files || '[]');
420
+ console.log(`\n ${mine ? '✅' : '🔴'} [${lock.module_name}] — ${mine ? 'YOURS' : 'BLOCKED'}`);
421
+ console.log(` Instance: ${lock.instance_id}`);
422
+ if (files.length) console.log(` Files: ${files.join(', ')}`);
423
+ if (lock.purpose) console.log(` Purpose: ${lock.purpose}`);
424
+ console.log(` Expires: ${lock.expires_at}`);
425
+ }
426
+ }
427
+
428
+ if (s.waiters.length > 0) {
429
+ console.log(`\n ⏳ Waiting (${s.waiters.length}):`);
430
+ for (const w of s.waiters) {
431
+ console.log(` [${w.module_name}] ← ${w.instance_id} (since ${w.waiting_since})`);
432
+ }
433
+ }
434
+
435
+ console.log('\n══════════════════════════════════════════════════\n');
436
+ }
437
+
438
+ // ── CLI ───────────────────────────────────────────────────────────────────────
439
+
440
+ if (require.main === module) {
441
+ const args = process.argv.slice(2);
442
+ const cmd = args[0];
443
+ const opts = {};
444
+ for (const arg of args.slice(1)) {
445
+ const m = arg.match(/^--?([\w-]+)(?:=(.+))?$/);
446
+ if (m) opts[m[1]] = m[2] !== undefined ? m[2] : true;
447
+ }
448
+
449
+ if (!fs.existsSync(DB_PATH)) {
450
+ console.error('Lock Manager: memoria.db not found. Run akdd init first.');
451
+ process.exit(1);
452
+ }
453
+
454
+ let db;
455
+ try { db = openDB(); ensureSchema(db); }
456
+ catch(e) { console.error('Lock Manager DB error:', e.message); process.exit(1); }
457
+
458
+ switch(cmd) {
459
+ case 'acquire': {
460
+ const mod = opts.module || opts.m;
461
+ if (!mod) { console.error('--module required'); process.exit(1); }
462
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
463
+ const purpose = opts.purpose || opts.p || '';
464
+ const result = acquireModuleLock(db, mod, files, purpose);
465
+ if (result.success) {
466
+ console.log(`✅ Lock acquired: [${mod}]`);
467
+ if (files.length) console.log(` Files: ${result.files.join(', ')}`);
468
+ console.log(` Expires: ${result.expires_at}`);
469
+ } else {
470
+ console.error(`🔴 DENIED: ${result.reason}`);
471
+ if (result.deadlock) console.error(` 💀 DEADLOCK: ${result.deadlock_cycle}`);
472
+ if (result.conflicts) result.conflicts.forEach(c => console.error(` Conflict: ${c.file} → [${c.locked_by_module}]`));
473
+ process.exit(1);
474
+ }
475
+ break;
476
+ }
477
+ case 'release': {
478
+ const mod = opts.module || opts.m;
479
+ if (!mod) { console.error('--module required'); process.exit(1); }
480
+ const r = releaseModuleLock(db, mod);
481
+ if (r.success) console.log(`✅ Released: [${mod}]`);
482
+ else { console.error(`🔴 ${r.reason}`); process.exit(1); }
483
+ break;
484
+ }
485
+ case 'release-all': {
486
+ const r = releaseAll(db);
487
+ console.log(`✅ Released ${r.released_modules.length} locks: ${r.released_modules.join(', ') || '(none)'}`);
488
+ break;
489
+ }
490
+ case 'check': {
491
+ const files = opts.files ? opts.files.split(',').map(f=>f.trim()) : [];
492
+ if (!files.length) { console.error('--files required'); process.exit(1); }
493
+ const r = checkFiles(db, files);
494
+ if (r.safe) { console.log('✅ Files free — safe to proceed'); }
495
+ else {
496
+ console.error('🔴 Conflicts:');
497
+ r.conflicts.forEach(c => console.error(` ${c.file} → [${c.locked_by_module}]`));
498
+ process.exit(1);
499
+ }
500
+ break;
501
+ }
502
+ case 'acquire-schema': {
503
+ const r = acquireSchemaLock(db, opts.purpose || opts.p || 'migration');
504
+ if (r.success) console.log(`✅ Schema lock acquired — ${r.purpose} | Expires: ${r.expires_at}`);
505
+ else { console.error(`🔴 Schema locked: ${r.reason}`); process.exit(1); }
506
+ break;
507
+ }
508
+ case 'release-schema': {
509
+ const r = releaseSchemaLock(db);
510
+ if (r.success) console.log('✅ Schema lock released');
511
+ else console.error('⚠️ No schema lock owned by this instance');
512
+ break;
513
+ }
514
+ case 'renew': {
515
+ const mod = opts.module || opts.m;
516
+ if (!mod) { console.error('--module required'); process.exit(1); }
517
+ const r = renewLock(db, mod);
518
+ if (r.success) console.log(`✅ Renewed [${mod}] until ${r.renewed_until}`);
519
+ else { console.error('🔴 No lock to renew'); process.exit(1); }
520
+ break;
521
+ }
522
+ case 'wait': {
523
+ const mod = opts.module || opts.m;
524
+ if (!mod) { console.error('--module required'); process.exit(1); }
525
+ const timeout = parseInt(opts.timeout || '300');
526
+ const r = waitForLock(db, mod, timeout);
527
+ if (!r.success) { console.error(`🔴 ${r.reason}`); process.exit(1); }
528
+ break;
529
+ }
530
+ case 'status':
531
+ default: {
532
+ printStatus(getStatus(db));
533
+ break;
534
+ }
535
+ }
536
+ db.close();
537
+ }
538
+
539
+ module.exports = {
540
+ acquireModuleLock, releaseModuleLock, releaseAll,
541
+ acquireSchemaLock, releaseSchemaLock,
542
+ checkFiles, renewLock, waitForLock,
543
+ getStatus, detectDeadlock, normalizePath,
544
+ INSTANCE_ID,
545
+ };