chati-dev 3.2.5 → 3.3.0

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.
Files changed (98) hide show
  1. package/LICENSE +96 -0
  2. package/bin/chati.js +46 -0
  3. package/framework/agents/build/dev.md +122 -1
  4. package/framework/agents/deploy/devops.md +128 -3
  5. package/framework/agents/discover/brief.md +77 -15
  6. package/framework/agents/discover/brownfield-wu.md +2 -2
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +3 -3
  10. package/framework/agents/plan/phases.md +127 -2
  11. package/framework/agents/plan/tasks.md +127 -2
  12. package/framework/agents/plan/ux.md +269 -22
  13. package/framework/agents/quality/qa-implementation.md +172 -8
  14. package/framework/agents/quality/qa-planning.md +147 -2
  15. package/framework/config.yaml +9 -5
  16. package/framework/constitution.md +7 -1
  17. package/framework/context/quality.md +1 -1
  18. package/framework/context/root.md +1 -1
  19. package/framework/hooks/constitution-guard.js +18 -2
  20. package/framework/hooks/mode-governance.js +3 -3
  21. package/framework/hooks/read-protection.js +10 -2
  22. package/framework/i18n/en.yaml +6 -0
  23. package/framework/i18n/es.yaml +6 -0
  24. package/framework/i18n/fr.yaml +6 -0
  25. package/framework/i18n/pt.yaml +6 -0
  26. package/framework/orchestrator/chati.md +102 -6
  27. package/framework/schemas/task.schema.json +1 -1
  28. package/framework/tasks/architect-dep-audit.md +128 -0
  29. package/framework/tasks/architect-stack-selection.md +28 -0
  30. package/framework/workflows/brownfield-fullstack.yaml +2 -2
  31. package/framework/workflows/brownfield-service.yaml +2 -2
  32. package/framework/workflows/brownfield-ui.yaml +2 -2
  33. package/framework/workflows/greenfield-fullstack.yaml +6 -2
  34. package/framework/workflows/quick-flow.yaml +7 -5
  35. package/framework/workflows/standard-flow.yaml +171 -0
  36. package/package.json +4 -2
  37. package/src/api/index.js +129 -0
  38. package/src/autonomy/build-loop.js +93 -6
  39. package/src/autonomy/build-state.js +20 -2
  40. package/src/autonomy/cause-analyzer.js +177 -0
  41. package/src/autonomy/escalation.js +214 -0
  42. package/src/autonomy/safety-net.js +23 -5
  43. package/src/autonomy/worktree-manager.js +245 -0
  44. package/src/config/agent-customizer.js +227 -0
  45. package/src/config/ide-configs.js +57 -27
  46. package/src/decision/analyzer.js +148 -0
  47. package/src/decision/registry-healer.js +38 -21
  48. package/src/extensions/loader.js +151 -0
  49. package/src/extensions/registry.js +134 -0
  50. package/src/gates/circuit-breaker.js +32 -0
  51. package/src/gates/g3-implementation.js +30 -4
  52. package/src/gates/g4-qa-implementation.js +34 -5
  53. package/src/gates/gate-base.js +9 -0
  54. package/src/health/auto-fix.js +216 -0
  55. package/src/installer/core.js +24 -11
  56. package/src/installer/provider-overlay.js +82 -0
  57. package/src/installer/templates.js +22 -10
  58. package/src/installer/transaction.js +3 -2
  59. package/src/installer/validator.js +74 -0
  60. package/src/intelligence/context-status.js +9 -5
  61. package/src/intelligence/document-sharder.js +221 -0
  62. package/src/intelligence/elicitation.js +265 -0
  63. package/src/intelligence/timeline.js +5 -0
  64. package/src/memory/gotchas.js +78 -2
  65. package/src/merger/semantic-merger.js +292 -0
  66. package/src/orchestrator/agent-selector.js +20 -0
  67. package/src/orchestrator/handoff-engine.js +77 -0
  68. package/src/orchestrator/index.js +0 -8
  69. package/src/orchestrator/intent-classifier.js +182 -0
  70. package/src/orchestrator/pipeline-manager.js +125 -1
  71. package/src/orchestrator/session-manager.js +164 -2
  72. package/src/quality/metrics-collector.js +283 -0
  73. package/src/quality/test-runner.js +368 -0
  74. package/src/telemetry/collector.js +83 -0
  75. package/src/telemetry/config.js +119 -0
  76. package/src/telemetry/index.js +11 -0
  77. package/src/telemetry/schema.js +104 -0
  78. package/src/telemetry/sender.js +60 -0
  79. package/src/terminal/cli-registry.js +7 -1
  80. package/src/terminal/cost-tracker.js +197 -0
  81. package/src/terminal/handoff-parser.js +61 -4
  82. package/src/terminal/prompt-builder.js +56 -18
  83. package/src/terminal/rate-limiter.js +172 -0
  84. package/src/terminal/run-agent.js +39 -0
  85. package/src/terminal/run-parallel.js +22 -1
  86. package/src/terminal/spawner.js +181 -3
  87. package/src/upgrade/migrator.js +2 -2
  88. package/src/utils/event-bus.js +126 -0
  89. package/src/utils/file-lock.js +291 -0
  90. package/src/utils/schema-validator.js +226 -0
  91. package/src/wizard/i18n.js +11 -0
  92. package/src/wizard/index.js +42 -20
  93. package/src/wizard/questions.js +200 -39
  94. package/src/autonomy/execution-profile.js +0 -151
  95. package/src/intelligence/file-tracker.js +0 -117
  96. package/src/memory/gotchas-auto-capture.js +0 -253
  97. package/src/orchestrator/pipeline-state.js +0 -223
  98. package/src/terminal/wave-analyzer.js +0 -143
@@ -11,6 +11,19 @@ import { spawn } from 'child_process';
11
11
  import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
12
12
  import { getProvider } from './cli-registry.js';
13
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Constants
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Default maximum concurrent processes when spawning in parallel. */
19
+ export const DEFAULT_CONCURRENCY = 3;
20
+
21
+ /** Patterns that indicate a transient failure (worth retrying). */
22
+ export const TRANSIENT_PATTERNS = [
23
+ /rate limit/i, /too many requests/i, /429/, /503/,
24
+ /timeout/i, /ECONNRESET/, /ECONNREFUSED/,
25
+ ];
26
+
14
27
  // ---------------------------------------------------------------------------
15
28
  // Helpers
16
29
  // ---------------------------------------------------------------------------
@@ -150,6 +163,7 @@ export function buildSpawnCommand(config) {
150
163
  // Resolve CLI provider — defaults to claude for backwards compatibility
151
164
  const providerName = config.provider || 'claude';
152
165
  let command, args, prompt;
166
+ let providerFallback = null;
153
167
 
154
168
  try {
155
169
  const provider = getProvider(providerName);
@@ -159,6 +173,12 @@ export function buildSpawnCommand(config) {
159
173
  prompt = adapterResult.stdinPrompt;
160
174
  } catch (err) {
161
175
  // Fallback to claude if provider resolution fails (backwards compatibility)
176
+ providerFallback = {
177
+ requested: providerName,
178
+ actual: 'claude',
179
+ reason: err.message,
180
+ timestamp: new Date().toISOString(),
181
+ };
162
182
  console.error(`[chati] Provider "${providerName}" resolution failed: ${err.message}. Falling back to claude.`);
163
183
  command = 'claude';
164
184
  args = ['--print', '--dangerously-skip-permissions'];
@@ -170,7 +190,7 @@ export function buildSpawnCommand(config) {
170
190
  prompt = config.prompt || null;
171
191
  }
172
192
 
173
- return { command, args, env, terminalId, prompt };
193
+ return { command, args, env, terminalId, prompt, providerFallback };
174
194
  }
175
195
 
176
196
  /**
@@ -180,7 +200,7 @@ export function buildSpawnCommand(config) {
180
200
  * @returns {TerminalHandle}
181
201
  */
182
202
  export function spawnTerminal(config) {
183
- const { command, args, env, terminalId, prompt } = buildSpawnCommand(config);
203
+ const { command, args, env, terminalId, prompt, providerFallback } = buildSpawnCommand(config);
184
204
 
185
205
  const cwd = config.workingDir || process.cwd();
186
206
  const timeout = config.timeout || 300_000; // default 5 minutes
@@ -204,6 +224,8 @@ export function spawnTerminal(config) {
204
224
  agent: config.agent,
205
225
  taskId: config.taskId,
206
226
  model: config.model || 'unknown',
227
+ provider: config.provider || 'claude',
228
+ providerFallback,
207
229
  startedAt: new Date().toISOString(),
208
230
  status: 'running',
209
231
  exitCode: null,
@@ -285,6 +307,96 @@ export function spawnParallelGroup(configs) {
285
307
  };
286
308
  }
287
309
 
310
+ /**
311
+ * Spawn a group of terminals with concurrency control (pool pattern).
312
+ *
313
+ * Unlike `spawnParallelGroup` which launches all at once, this function
314
+ * limits the number of simultaneously running processes. When one process
315
+ * exits, the next in the queue is spawned.
316
+ *
317
+ * @param {SpawnConfig[]} configs
318
+ * @param {{ maxConcurrency?: number }} [options={}]
319
+ * @returns {Promise<{ groupId: string, terminals: TerminalHandle[], startedAt: string }>}
320
+ * @throws {Error} When write scope conflicts are detected
321
+ */
322
+ export async function spawnParallelGroupAsync(configs, options = {}) {
323
+ if (!Array.isArray(configs) || configs.length === 0) {
324
+ throw new Error('spawnParallelGroupAsync requires a non-empty array of configs');
325
+ }
326
+
327
+ const maxConcurrency = options.maxConcurrency || DEFAULT_CONCURRENCY;
328
+
329
+ const validation = validateWriteScopes(configs);
330
+ if (!validation.valid) {
331
+ const details = validation.conflicts
332
+ .map(c => `${c.agents.join(' vs ')} on ${c.path}`)
333
+ .join('; ');
334
+ throw new Error(`Write scope conflicts detected: ${details}`);
335
+ }
336
+
337
+ const groupId = `group-${Date.now()}`;
338
+ const startedAt = new Date().toISOString();
339
+
340
+ // If within concurrency limit, spawn all at once (fast path)
341
+ if (configs.length <= maxConcurrency) {
342
+ const terminals = configs.map(cfg => spawnTerminal(cfg));
343
+ return { groupId, terminals, startedAt };
344
+ }
345
+
346
+ // Pool pattern: spawn up to maxConcurrency, refill as processes exit
347
+ const terminals = [];
348
+ const queue = [...configs];
349
+ const active = new Set();
350
+
351
+ /**
352
+ * Wait for a terminal to exit. Returns a Promise that resolves
353
+ * when the terminal's process emits 'exit'.
354
+ */
355
+ function waitForExit(handle) {
356
+ if (handle.status !== 'running') {
357
+ return Promise.resolve();
358
+ }
359
+ return new Promise(resolve => {
360
+ if (!handle.process) {
361
+ resolve();
362
+ return;
363
+ }
364
+ handle.process.once('exit', () => resolve());
365
+ });
366
+ }
367
+
368
+ // Fill initial pool
369
+ while (queue.length > 0 && active.size < maxConcurrency) {
370
+ const cfg = queue.shift();
371
+ const handle = spawnTerminal(cfg);
372
+ terminals.push(handle);
373
+ active.add(handle);
374
+ }
375
+
376
+ // Process remaining queue as slots free up
377
+ while (queue.length > 0) {
378
+ // Wait for ANY active process to exit
379
+ await Promise.race([...active].map(h => waitForExit(h)));
380
+
381
+ // Remove exited processes from active set
382
+ for (const h of active) {
383
+ if (h.status !== 'running') {
384
+ active.delete(h);
385
+ }
386
+ }
387
+
388
+ // Fill freed slots
389
+ while (queue.length > 0 && active.size < maxConcurrency) {
390
+ const cfg = queue.shift();
391
+ const handle = spawnTerminal(cfg);
392
+ terminals.push(handle);
393
+ active.add(handle);
394
+ }
395
+ }
396
+
397
+ return { groupId, terminals, startedAt };
398
+ }
399
+
288
400
  /**
289
401
  * Gracefully kill a spawned terminal.
290
402
  * Sends SIGTERM first; if the process is still alive after 5 seconds,
@@ -336,7 +448,7 @@ export function killTerminal(handle) {
336
448
  */
337
449
  export function getTerminalStatus(handle) {
338
450
  if (!handle) {
339
- return { id: 'unknown', agent: 'unknown', model: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
451
+ return { id: 'unknown', agent: 'unknown', model: 'unknown', provider: 'unknown', status: 'unknown', elapsed: 0, exitCode: null, providerFallback: null };
340
452
  }
341
453
 
342
454
  const elapsed = Date.now() - new Date(handle.startedAt).getTime();
@@ -345,8 +457,74 @@ export function getTerminalStatus(handle) {
345
457
  id: handle.id,
346
458
  agent: handle.agent,
347
459
  model: handle.model || 'unknown',
460
+ provider: handle.provider || 'unknown',
348
461
  status: handle.status,
349
462
  elapsed,
350
463
  exitCode: handle.exitCode,
464
+ providerFallback: handle.providerFallback || null,
351
465
  };
352
466
  }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Retry / Backoff
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * Determine whether a terminal failure is transient (worth retrying).
474
+ *
475
+ * @param {number} exitCode - Process exit code
476
+ * @param {string[]|string} stderr - Captured stderr output
477
+ * @returns {boolean}
478
+ */
479
+ export function isTransientFailure(exitCode, stderr) {
480
+ if (exitCode === 0) return false;
481
+ const str = Array.isArray(stderr) ? stderr.join('') : (stderr || '');
482
+ return TRANSIENT_PATTERNS.some(p => p.test(str));
483
+ }
484
+
485
+ /**
486
+ * Spawn a terminal with automatic retry on transient failures.
487
+ *
488
+ * Wraps `spawnTerminal()` with exponential backoff.
489
+ * Non-transient failures are returned immediately without retry.
490
+ *
491
+ * @param {SpawnConfig} config
492
+ * @param {{ maxRetries?: number, baseDelay?: number, shouldRetry?: function }} [retryOptions={}]
493
+ * @returns {Promise<TerminalHandle>}
494
+ */
495
+ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
496
+ const maxRetries = retryOptions.maxRetries ?? 2;
497
+ const baseDelay = retryOptions.baseDelay ?? 2000;
498
+ const shouldRetry = retryOptions.shouldRetry || isTransientFailure;
499
+
500
+ let lastHandle = null;
501
+
502
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
503
+ const handle = spawnTerminal(config);
504
+ lastHandle = handle;
505
+
506
+ // Wait for process to exit
507
+ await new Promise((resolve) => {
508
+ if (!handle.process) { resolve(); return; }
509
+ if (handle.status !== 'running') { resolve(); return; }
510
+ handle.process.once('exit', () => resolve());
511
+ });
512
+
513
+ // Success — return immediately
514
+ if (handle.exitCode === 0) {
515
+ return handle;
516
+ }
517
+
518
+ // Check if failure is transient and retries remain
519
+ if (attempt < maxRetries && shouldRetry(handle.exitCode, handle.stderr)) {
520
+ const delay = baseDelay * Math.pow(2, attempt);
521
+ await new Promise(resolve => setTimeout(resolve, delay));
522
+ continue;
523
+ }
524
+
525
+ // Non-transient or out of retries — return as-is
526
+ return handle;
527
+ }
528
+
529
+ return lastHandle;
530
+ }
@@ -149,8 +149,8 @@ export async function runMigrations(targetDir, fromVersion, toVersion) {
149
149
  for (const step of migration.rollback) {
150
150
  executeMigrationStep(targetDir, step);
151
151
  }
152
- } catch {
153
- // Rollback failed - return error
152
+ } catch (rollbackErr) {
153
+ console.error('[chati] migration rollback failed:', rollbackErr?.message);
154
154
  }
155
155
 
156
156
  return {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @fileoverview Singleton event bus for the chati.dev framework.
3
+ *
4
+ * Provides a centralized EventEmitter for observing state changes
5
+ * across subsystems (build-loop, spawner, pipeline, safety-net).
6
+ */
7
+
8
+ /**
9
+ * @deprecated Not currently imported by any production module.
10
+ * Retained for potential future integration. Remove if still unused by v4.0.
11
+ */
12
+
13
+ import { EventEmitter } from 'node:events';
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Event Names
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * All framework event names, organized by subsystem.
21
+ */
22
+ export const EVENTS = {
23
+ // Build loop
24
+ BUILD_STARTED: 'build:started',
25
+ BUILD_COMPLETED: 'build:completed',
26
+ BUILD_FAILED: 'build:failed',
27
+ TASK_STARTED: 'build:task:started',
28
+ TASK_COMPLETED: 'build:task:completed',
29
+ TASK_FAILED: 'build:task:failed',
30
+ TASK_EXHAUSTED: 'build:task:exhausted',
31
+
32
+ // Spawner
33
+ TERMINAL_SPAWNED: 'terminal:spawned',
34
+ TERMINAL_EXITED: 'terminal:exited',
35
+ TERMINAL_KILLED: 'terminal:killed',
36
+
37
+ // Pipeline
38
+ PHASE_ADVANCED: 'pipeline:phase:advanced',
39
+ AGENT_COMPLETED: 'pipeline:agent:completed',
40
+ QA_GATE_PASSED: 'pipeline:qa:passed',
41
+ QA_GATE_FAILED: 'pipeline:qa:failed',
42
+
43
+ // Safety
44
+ SAFETY_TRIGGERED: 'safety:triggered',
45
+
46
+ // Health
47
+ HEALTH_CHECK_COMPLETED: 'health:completed',
48
+
49
+ // Escalation
50
+ ESCALATION_TRIGGERED: 'escalation:triggered',
51
+ ESCALATION_PAUSED: 'escalation:paused',
52
+ };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Bus Instance
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const bus = new EventEmitter();
59
+ bus.setMaxListeners(50);
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Public API
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Emit an event with data. Automatically adds a timestamp.
67
+ *
68
+ * @param {string} event - Event name from EVENTS
69
+ * @param {object} [data={}] - Event payload
70
+ */
71
+ export function emit(event, data = {}) {
72
+ bus.emit(event, { ...data, timestamp: Date.now() });
73
+ }
74
+
75
+ /**
76
+ * Subscribe to an event.
77
+ *
78
+ * @param {string} event - Event name
79
+ * @param {function} handler - Event handler
80
+ */
81
+ export function on(event, handler) {
82
+ bus.on(event, handler);
83
+ }
84
+
85
+ /**
86
+ * Subscribe to an event once.
87
+ *
88
+ * @param {string} event - Event name
89
+ * @param {function} handler - Event handler
90
+ */
91
+ export function once(event, handler) {
92
+ bus.once(event, handler);
93
+ }
94
+
95
+ /**
96
+ * Unsubscribe from an event.
97
+ *
98
+ * @param {string} event - Event name
99
+ * @param {function} handler - Event handler to remove
100
+ */
101
+ export function off(event, handler) {
102
+ bus.off(event, handler);
103
+ }
104
+
105
+ /**
106
+ * Remove all listeners for an event, or all events if no event specified.
107
+ *
108
+ * @param {string} [event] - Event name (optional)
109
+ */
110
+ export function removeAllListeners(event) {
111
+ if (event) {
112
+ bus.removeAllListeners(event);
113
+ } else {
114
+ bus.removeAllListeners();
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Get the count of listeners for an event.
120
+ *
121
+ * @param {string} event - Event name
122
+ * @returns {number}
123
+ */
124
+ export function listenerCount(event) {
125
+ return bus.listenerCount(event);
126
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * @fileoverview Advisory file locking using .lock files + PID.
3
+ *
4
+ * Prevents concurrent writes to shared state files (session.yaml,
5
+ * build-state.json) during multi-terminal execution.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'fs';
9
+ import { dirname } from 'path';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ /** Default lock acquisition timeout in ms */
16
+ export const DEFAULT_TIMEOUT = 5000;
17
+
18
+ /** Default retry interval in ms */
19
+ export const DEFAULT_RETRY_INTERVAL = 100;
20
+
21
+ /** Default stale threshold in ms (30 seconds) */
22
+ export const STALE_THRESHOLD_MS = 30_000;
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Lock Management
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Build the lock file path for a given file.
30
+ *
31
+ * @param {string} filePath
32
+ * @returns {string}
33
+ */
34
+ function getLockPath(filePath) {
35
+ return `${filePath}.lock`;
36
+ }
37
+
38
+ /**
39
+ * Check if a process is still running.
40
+ *
41
+ * @param {number} pid
42
+ * @returns {boolean}
43
+ */
44
+ function isProcessAlive(pid) {
45
+ try {
46
+ process.kill(pid, 0);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Read lock file contents.
55
+ *
56
+ * @param {string} lockPath
57
+ * @returns {{ pid: number, timestamp: number, hostname: string }|null}
58
+ */
59
+ function readLockFile(lockPath) {
60
+ try {
61
+ const content = readFileSync(lockPath, 'utf-8');
62
+ return JSON.parse(content);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Write lock file with current process info.
70
+ *
71
+ * @param {string} lockPath
72
+ */
73
+ function writeLockFile(lockPath) {
74
+ const dir = dirname(lockPath);
75
+ if (!existsSync(dir)) {
76
+ mkdirSync(dir, { recursive: true });
77
+ }
78
+
79
+ const lockData = {
80
+ pid: process.pid,
81
+ timestamp: Date.now(),
82
+ hostname: typeof globalThis !== 'undefined' ? (globalThis.process?.env?.HOSTNAME || 'localhost') : 'localhost',
83
+ };
84
+ writeFileSync(lockPath, JSON.stringify(lockData), 'utf-8');
85
+ }
86
+
87
+ /**
88
+ * Check if a lock is stale (owner process dead or lock too old).
89
+ *
90
+ * @param {string} lockPath
91
+ * @param {object} [options]
92
+ * @param {boolean} [options.stalePidCheck=true]
93
+ * @returns {boolean}
94
+ */
95
+ function isLockStale(lockPath, options = {}) {
96
+ const { stalePidCheck = true } = options;
97
+ const lockData = readLockFile(lockPath);
98
+
99
+ if (!lockData) return true;
100
+
101
+ // Check if lock is too old
102
+ if (Date.now() - lockData.timestamp > STALE_THRESHOLD_MS) {
103
+ return true;
104
+ }
105
+
106
+ // Check if owner process is still alive
107
+ if (stalePidCheck && lockData.pid && !isProcessAlive(lockData.pid)) {
108
+ return true;
109
+ }
110
+
111
+ return false;
112
+ }
113
+
114
+ /**
115
+ * Acquire an advisory lock on a file.
116
+ *
117
+ * @param {string} filePath - Path to the file to lock
118
+ * @param {object} [options]
119
+ * @param {number} [options.timeout=5000] - Max time to wait for lock
120
+ * @param {number} [options.retryInterval=100] - Time between retries
121
+ * @param {boolean} [options.stalePidCheck=true] - Check if lock owner PID is alive
122
+ * @returns {{ acquired: boolean, lockPath: string, release: () => void }}
123
+ */
124
+ export function acquireLock(filePath, options = {}) {
125
+ const {
126
+ timeout = DEFAULT_TIMEOUT,
127
+ retryInterval = DEFAULT_RETRY_INTERVAL,
128
+ stalePidCheck = true,
129
+ } = options;
130
+
131
+ const lockPath = getLockPath(filePath);
132
+ const startTime = Date.now();
133
+
134
+ while (true) {
135
+ // If lock file doesn't exist, acquire it
136
+ if (!existsSync(lockPath)) {
137
+ writeLockFile(lockPath);
138
+ return {
139
+ acquired: true,
140
+ lockPath,
141
+ release: () => releaseLock(lockPath),
142
+ };
143
+ }
144
+
145
+ // If lock is stale, remove and acquire
146
+ if (isLockStale(lockPath, { stalePidCheck })) {
147
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
148
+ writeLockFile(lockPath);
149
+ return {
150
+ acquired: true,
151
+ lockPath,
152
+ release: () => releaseLock(lockPath),
153
+ };
154
+ }
155
+
156
+ // Check timeout
157
+ if (Date.now() - startTime >= timeout) {
158
+ return {
159
+ acquired: false,
160
+ lockPath,
161
+ release: () => {},
162
+ };
163
+ }
164
+
165
+ // Busy wait (synchronous - acceptable for short lock durations)
166
+ const waitUntil = Date.now() + retryInterval;
167
+ while (Date.now() < waitUntil) { /* spin */ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Release an advisory lock.
173
+ *
174
+ * @param {string} lockPath - Path to the lock file
175
+ */
176
+ export function releaseLock(lockPath) {
177
+ try {
178
+ if (existsSync(lockPath)) {
179
+ const lockData = readLockFile(lockPath);
180
+ // Only release if we own it
181
+ if (!lockData || lockData.pid === process.pid) {
182
+ unlinkSync(lockPath);
183
+ }
184
+ }
185
+ } catch {
186
+ // Lock may already be released — ignore
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Check if a file is currently locked.
192
+ *
193
+ * @param {string} filePath
194
+ * @returns {boolean}
195
+ */
196
+ export function isLocked(filePath) {
197
+ const lockPath = getLockPath(filePath);
198
+ if (!existsSync(lockPath)) return false;
199
+ return !isLockStale(lockPath);
200
+ }
201
+
202
+ /**
203
+ * Execute a function while holding a lock on a file.
204
+ *
205
+ * @param {string} filePath - File to lock
206
+ * @param {function(): T|Promise<T>} fn - Function to execute while holding lock
207
+ * @param {object} [options] - Lock options
208
+ * @returns {Promise<T>}
209
+ * @template T
210
+ */
211
+ export async function withLock(filePath, fn, options = {}) {
212
+ const lock = acquireLock(filePath, options);
213
+
214
+ if (!lock.acquired) {
215
+ throw new Error(`Failed to acquire lock on ${filePath}`);
216
+ }
217
+
218
+ try {
219
+ return await fn();
220
+ } finally {
221
+ lock.release();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Acquire a user-aware lock on a file.
227
+ * Only the same userId can re-acquire an existing lock.
228
+ *
229
+ * @param {string} filePath - File to lock
230
+ * @param {string} userId - User identifier
231
+ * @param {object} [options] - Lock options
232
+ * @param {number} [options.timeout=5000] - Max time to wait
233
+ * @param {number} [options.retryInterval=100] - Time between retries
234
+ * @returns {{ acquired: boolean, lockPath: string, owner: string|null, release: () => void }}
235
+ */
236
+ export function acquireUserLock(filePath, userId, options = {}) {
237
+ if (!userId) {
238
+ return {
239
+ acquired: false,
240
+ lockPath: `${filePath}.lock`,
241
+ owner: null,
242
+ release: () => {},
243
+ };
244
+ }
245
+
246
+ const lockPath = `${filePath}.lock`;
247
+
248
+ // Check if another user holds the lock
249
+ if (existsSync(lockPath)) {
250
+ const lockData = readLockFile(lockPath);
251
+
252
+ if (lockData && lockData.userId && lockData.userId !== userId) {
253
+ // Check if the lock is stale
254
+ if (!isLockStale(lockPath, options)) {
255
+ return {
256
+ acquired: false,
257
+ lockPath,
258
+ owner: lockData.userId,
259
+ release: () => {},
260
+ };
261
+ }
262
+ // Stale lock — remove and continue
263
+ try { unlinkSync(lockPath); } catch { /* ignore */ }
264
+ }
265
+ }
266
+
267
+ // Use standard lock acquisition
268
+ const lock = acquireLock(filePath, options);
269
+
270
+ if (lock.acquired) {
271
+ // Overwrite lock file with user info
272
+ const dir = dirname(lockPath);
273
+ if (!existsSync(dir)) {
274
+ mkdirSync(dir, { recursive: true });
275
+ }
276
+ const lockData = {
277
+ pid: process.pid,
278
+ timestamp: Date.now(),
279
+ userId,
280
+ hostname: typeof globalThis !== 'undefined' ? (globalThis.process?.env?.HOSTNAME || 'localhost') : 'localhost',
281
+ };
282
+ writeFileSync(lockPath, JSON.stringify(lockData), 'utf-8');
283
+ }
284
+
285
+ return {
286
+ acquired: lock.acquired,
287
+ lockPath: lock.lockPath,
288
+ owner: userId,
289
+ release: lock.release,
290
+ };
291
+ }