@lumenflow/core 1.0.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 (263) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +119 -0
  3. package/dist/active-wu-detector.d.ts +33 -0
  4. package/dist/active-wu-detector.js +106 -0
  5. package/dist/adapters/filesystem-metrics.adapter.d.ts +108 -0
  6. package/dist/adapters/filesystem-metrics.adapter.js +519 -0
  7. package/dist/adapters/terminal-renderer.adapter.d.ts +106 -0
  8. package/dist/adapters/terminal-renderer.adapter.js +337 -0
  9. package/dist/arg-parser.d.ts +63 -0
  10. package/dist/arg-parser.js +560 -0
  11. package/dist/backlog-editor.d.ts +98 -0
  12. package/dist/backlog-editor.js +179 -0
  13. package/dist/backlog-generator.d.ts +111 -0
  14. package/dist/backlog-generator.js +381 -0
  15. package/dist/backlog-parser.d.ts +45 -0
  16. package/dist/backlog-parser.js +102 -0
  17. package/dist/backlog-sync-validator.d.ts +78 -0
  18. package/dist/backlog-sync-validator.js +294 -0
  19. package/dist/branch-drift.d.ts +34 -0
  20. package/dist/branch-drift.js +51 -0
  21. package/dist/cleanup-install-config.d.ts +33 -0
  22. package/dist/cleanup-install-config.js +37 -0
  23. package/dist/cleanup-lock.d.ts +139 -0
  24. package/dist/cleanup-lock.js +313 -0
  25. package/dist/code-path-validator.d.ts +146 -0
  26. package/dist/code-path-validator.js +537 -0
  27. package/dist/code-paths-overlap.d.ts +55 -0
  28. package/dist/code-paths-overlap.js +245 -0
  29. package/dist/commands-logger.d.ts +77 -0
  30. package/dist/commands-logger.js +254 -0
  31. package/dist/commit-message-utils.d.ts +25 -0
  32. package/dist/commit-message-utils.js +41 -0
  33. package/dist/compliance-parser.d.ts +150 -0
  34. package/dist/compliance-parser.js +507 -0
  35. package/dist/constants/backlog-patterns.d.ts +20 -0
  36. package/dist/constants/backlog-patterns.js +23 -0
  37. package/dist/constants/dora-constants.d.ts +49 -0
  38. package/dist/constants/dora-constants.js +53 -0
  39. package/dist/constants/gate-constants.d.ts +15 -0
  40. package/dist/constants/gate-constants.js +15 -0
  41. package/dist/constants/linter-constants.d.ts +16 -0
  42. package/dist/constants/linter-constants.js +16 -0
  43. package/dist/constants/tokenizer-constants.d.ts +15 -0
  44. package/dist/constants/tokenizer-constants.js +15 -0
  45. package/dist/core/scope-checker.d.ts +97 -0
  46. package/dist/core/scope-checker.js +163 -0
  47. package/dist/core/tool-runner.d.ts +161 -0
  48. package/dist/core/tool-runner.js +393 -0
  49. package/dist/core/tool.constants.d.ts +105 -0
  50. package/dist/core/tool.constants.js +101 -0
  51. package/dist/core/tool.schemas.d.ts +226 -0
  52. package/dist/core/tool.schemas.js +226 -0
  53. package/dist/core/worktree-guard.d.ts +130 -0
  54. package/dist/core/worktree-guard.js +242 -0
  55. package/dist/coverage-gate.d.ts +108 -0
  56. package/dist/coverage-gate.js +196 -0
  57. package/dist/date-utils.d.ts +75 -0
  58. package/dist/date-utils.js +140 -0
  59. package/dist/dependency-graph.d.ts +142 -0
  60. package/dist/dependency-graph.js +550 -0
  61. package/dist/dependency-guard.d.ts +54 -0
  62. package/dist/dependency-guard.js +142 -0
  63. package/dist/dependency-validator.d.ts +105 -0
  64. package/dist/dependency-validator.js +154 -0
  65. package/dist/docs-path-validator.d.ts +36 -0
  66. package/dist/docs-path-validator.js +95 -0
  67. package/dist/domain/orchestration.constants.d.ts +99 -0
  68. package/dist/domain/orchestration.constants.js +97 -0
  69. package/dist/domain/orchestration.schemas.d.ts +280 -0
  70. package/dist/domain/orchestration.schemas.js +211 -0
  71. package/dist/domain/orchestration.types.d.ts +133 -0
  72. package/dist/domain/orchestration.types.js +12 -0
  73. package/dist/error-handler.d.ts +116 -0
  74. package/dist/error-handler.js +136 -0
  75. package/dist/file-classifiers.d.ts +62 -0
  76. package/dist/file-classifiers.js +108 -0
  77. package/dist/gates-agent-mode.d.ts +81 -0
  78. package/dist/gates-agent-mode.js +94 -0
  79. package/dist/generate-traceability.d.ts +107 -0
  80. package/dist/generate-traceability.js +411 -0
  81. package/dist/git-adapter.d.ts +395 -0
  82. package/dist/git-adapter.js +649 -0
  83. package/dist/git-staged-validator.d.ts +32 -0
  84. package/dist/git-staged-validator.js +48 -0
  85. package/dist/hardcoded-strings.d.ts +61 -0
  86. package/dist/hardcoded-strings.js +270 -0
  87. package/dist/incremental-lint.d.ts +78 -0
  88. package/dist/incremental-lint.js +129 -0
  89. package/dist/incremental-test.d.ts +39 -0
  90. package/dist/incremental-test.js +61 -0
  91. package/dist/index.d.ts +42 -0
  92. package/dist/index.js +61 -0
  93. package/dist/invariants/check-automated-tests.d.ts +50 -0
  94. package/dist/invariants/check-automated-tests.js +166 -0
  95. package/dist/invariants-runner.d.ts +103 -0
  96. package/dist/invariants-runner.js +527 -0
  97. package/dist/lane-checker.d.ts +50 -0
  98. package/dist/lane-checker.js +319 -0
  99. package/dist/lane-inference.d.ts +39 -0
  100. package/dist/lane-inference.js +195 -0
  101. package/dist/lane-lock.d.ts +211 -0
  102. package/dist/lane-lock.js +474 -0
  103. package/dist/lane-validator.d.ts +48 -0
  104. package/dist/lane-validator.js +114 -0
  105. package/dist/logs-lib.d.ts +104 -0
  106. package/dist/logs-lib.js +207 -0
  107. package/dist/lumenflow-config-schema.d.ts +272 -0
  108. package/dist/lumenflow-config-schema.js +207 -0
  109. package/dist/lumenflow-config.d.ts +95 -0
  110. package/dist/lumenflow-config.js +236 -0
  111. package/dist/manual-test-validator.d.ts +80 -0
  112. package/dist/manual-test-validator.js +200 -0
  113. package/dist/merge-lock.d.ts +115 -0
  114. package/dist/merge-lock.js +251 -0
  115. package/dist/micro-worktree.d.ts +159 -0
  116. package/dist/micro-worktree.js +427 -0
  117. package/dist/migration-deployer.d.ts +69 -0
  118. package/dist/migration-deployer.js +151 -0
  119. package/dist/orchestration-advisory-loader.d.ts +28 -0
  120. package/dist/orchestration-advisory-loader.js +87 -0
  121. package/dist/orchestration-advisory.d.ts +58 -0
  122. package/dist/orchestration-advisory.js +94 -0
  123. package/dist/orchestration-di.d.ts +48 -0
  124. package/dist/orchestration-di.js +57 -0
  125. package/dist/orchestration-rules.d.ts +57 -0
  126. package/dist/orchestration-rules.js +201 -0
  127. package/dist/orphan-detector.d.ts +131 -0
  128. package/dist/orphan-detector.js +226 -0
  129. package/dist/path-classifiers.d.ts +57 -0
  130. package/dist/path-classifiers.js +93 -0
  131. package/dist/piped-command-detector.d.ts +34 -0
  132. package/dist/piped-command-detector.js +64 -0
  133. package/dist/ports/dashboard-renderer.port.d.ts +112 -0
  134. package/dist/ports/dashboard-renderer.port.js +25 -0
  135. package/dist/ports/metrics-collector.port.d.ts +132 -0
  136. package/dist/ports/metrics-collector.port.js +26 -0
  137. package/dist/process-detector.d.ts +84 -0
  138. package/dist/process-detector.js +172 -0
  139. package/dist/prompt-linter.d.ts +72 -0
  140. package/dist/prompt-linter.js +312 -0
  141. package/dist/prompt-monitor.d.ts +15 -0
  142. package/dist/prompt-monitor.js +205 -0
  143. package/dist/rebase-artifact-cleanup.d.ts +145 -0
  144. package/dist/rebase-artifact-cleanup.js +433 -0
  145. package/dist/retry-strategy.d.ts +189 -0
  146. package/dist/retry-strategy.js +283 -0
  147. package/dist/risk-detector.d.ts +108 -0
  148. package/dist/risk-detector.js +252 -0
  149. package/dist/rollback-utils.d.ts +76 -0
  150. package/dist/rollback-utils.js +104 -0
  151. package/dist/section-headings.d.ts +43 -0
  152. package/dist/section-headings.js +49 -0
  153. package/dist/spawn-escalation.d.ts +90 -0
  154. package/dist/spawn-escalation.js +253 -0
  155. package/dist/spawn-monitor.d.ts +229 -0
  156. package/dist/spawn-monitor.js +672 -0
  157. package/dist/spawn-recovery.d.ts +82 -0
  158. package/dist/spawn-recovery.js +298 -0
  159. package/dist/spawn-registry-schema.d.ts +98 -0
  160. package/dist/spawn-registry-schema.js +108 -0
  161. package/dist/spawn-registry-store.d.ts +146 -0
  162. package/dist/spawn-registry-store.js +273 -0
  163. package/dist/spawn-tree.d.ts +121 -0
  164. package/dist/spawn-tree.js +285 -0
  165. package/dist/stamp-status-validator.d.ts +84 -0
  166. package/dist/stamp-status-validator.js +134 -0
  167. package/dist/stamp-utils.d.ts +100 -0
  168. package/dist/stamp-utils.js +229 -0
  169. package/dist/state-machine.d.ts +26 -0
  170. package/dist/state-machine.js +83 -0
  171. package/dist/system-map-validator.d.ts +80 -0
  172. package/dist/system-map-validator.js +272 -0
  173. package/dist/telemetry.d.ts +80 -0
  174. package/dist/telemetry.js +213 -0
  175. package/dist/token-counter.d.ts +51 -0
  176. package/dist/token-counter.js +145 -0
  177. package/dist/usecases/get-dashboard-data.usecase.d.ts +52 -0
  178. package/dist/usecases/get-dashboard-data.usecase.js +61 -0
  179. package/dist/usecases/get-suggestions.usecase.d.ts +100 -0
  180. package/dist/usecases/get-suggestions.usecase.js +153 -0
  181. package/dist/user-normalizer.d.ts +41 -0
  182. package/dist/user-normalizer.js +141 -0
  183. package/dist/validators/phi-constants.d.ts +97 -0
  184. package/dist/validators/phi-constants.js +152 -0
  185. package/dist/validators/phi-scanner.d.ts +58 -0
  186. package/dist/validators/phi-scanner.js +215 -0
  187. package/dist/worktree-ownership.d.ts +50 -0
  188. package/dist/worktree-ownership.js +74 -0
  189. package/dist/worktree-scanner.d.ts +103 -0
  190. package/dist/worktree-scanner.js +168 -0
  191. package/dist/worktree-symlink.d.ts +99 -0
  192. package/dist/worktree-symlink.js +359 -0
  193. package/dist/wu-backlog-updater.d.ts +17 -0
  194. package/dist/wu-backlog-updater.js +37 -0
  195. package/dist/wu-checkpoint.d.ts +124 -0
  196. package/dist/wu-checkpoint.js +233 -0
  197. package/dist/wu-claim-helpers.d.ts +26 -0
  198. package/dist/wu-claim-helpers.js +63 -0
  199. package/dist/wu-claim-resume.d.ts +106 -0
  200. package/dist/wu-claim-resume.js +276 -0
  201. package/dist/wu-consistency-checker.d.ts +95 -0
  202. package/dist/wu-consistency-checker.js +567 -0
  203. package/dist/wu-constants.d.ts +1275 -0
  204. package/dist/wu-constants.js +1382 -0
  205. package/dist/wu-create-validators.d.ts +42 -0
  206. package/dist/wu-create-validators.js +93 -0
  207. package/dist/wu-done-branch-only.d.ts +63 -0
  208. package/dist/wu-done-branch-only.js +191 -0
  209. package/dist/wu-done-messages.d.ts +119 -0
  210. package/dist/wu-done-messages.js +185 -0
  211. package/dist/wu-done-pr.d.ts +72 -0
  212. package/dist/wu-done-pr.js +174 -0
  213. package/dist/wu-done-retry-helpers.d.ts +85 -0
  214. package/dist/wu-done-retry-helpers.js +172 -0
  215. package/dist/wu-done-ui.d.ts +37 -0
  216. package/dist/wu-done-ui.js +69 -0
  217. package/dist/wu-done-validators.d.ts +411 -0
  218. package/dist/wu-done-validators.js +1229 -0
  219. package/dist/wu-done-worktree.d.ts +182 -0
  220. package/dist/wu-done-worktree.js +1097 -0
  221. package/dist/wu-helpers.d.ts +128 -0
  222. package/dist/wu-helpers.js +248 -0
  223. package/dist/wu-lint.d.ts +70 -0
  224. package/dist/wu-lint.js +234 -0
  225. package/dist/wu-paths.d.ts +171 -0
  226. package/dist/wu-paths.js +178 -0
  227. package/dist/wu-preflight-validators.d.ts +86 -0
  228. package/dist/wu-preflight-validators.js +251 -0
  229. package/dist/wu-recovery.d.ts +138 -0
  230. package/dist/wu-recovery.js +341 -0
  231. package/dist/wu-repair-core.d.ts +131 -0
  232. package/dist/wu-repair-core.js +669 -0
  233. package/dist/wu-schema-normalization.d.ts +17 -0
  234. package/dist/wu-schema-normalization.js +82 -0
  235. package/dist/wu-schema.d.ts +793 -0
  236. package/dist/wu-schema.js +881 -0
  237. package/dist/wu-spawn-helpers.d.ts +121 -0
  238. package/dist/wu-spawn-helpers.js +271 -0
  239. package/dist/wu-spawn.d.ts +158 -0
  240. package/dist/wu-spawn.js +1306 -0
  241. package/dist/wu-state-schema.d.ts +213 -0
  242. package/dist/wu-state-schema.js +156 -0
  243. package/dist/wu-state-store.d.ts +264 -0
  244. package/dist/wu-state-store.js +691 -0
  245. package/dist/wu-status-transition.d.ts +63 -0
  246. package/dist/wu-status-transition.js +382 -0
  247. package/dist/wu-status-updater.d.ts +25 -0
  248. package/dist/wu-status-updater.js +116 -0
  249. package/dist/wu-transaction-collectors.d.ts +116 -0
  250. package/dist/wu-transaction-collectors.js +272 -0
  251. package/dist/wu-transaction.d.ts +170 -0
  252. package/dist/wu-transaction.js +273 -0
  253. package/dist/wu-validation-constants.d.ts +60 -0
  254. package/dist/wu-validation-constants.js +66 -0
  255. package/dist/wu-validation.d.ts +118 -0
  256. package/dist/wu-validation.js +243 -0
  257. package/dist/wu-validator.d.ts +62 -0
  258. package/dist/wu-validator.js +325 -0
  259. package/dist/wu-yaml-fixer.d.ts +97 -0
  260. package/dist/wu-yaml-fixer.js +264 -0
  261. package/dist/wu-yaml.d.ts +86 -0
  262. package/dist/wu-yaml.js +222 -0
  263. package/package.json +114 -0
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lane Lock Module
4
+ *
5
+ * Provides atomic file-based locking to prevent TOCTOU (Time Of Check To Time Of Use)
6
+ * race conditions when multiple agents try to claim WUs in the same lane simultaneously.
7
+ *
8
+ * The lock file is created atomically using the 'wx' flag (exclusive create),
9
+ * which fails if the file already exists. This prevents the race condition where
10
+ * parallel agents could both read status.md before either updates it.
11
+ *
12
+ * Lock file location: .beacon/locks/<lane-kebab>.lock
13
+ * Lock file format: JSON with wuId, timestamp, agentSession, pid
14
+ *
15
+ * @see WU-1603 - Race condition fix for wu:claim
16
+ */
17
+ export interface LockMetadata {
18
+ wuId: string;
19
+ timestamp: string;
20
+ agentSession: string | null;
21
+ pid: number;
22
+ lane: string;
23
+ }
24
+ interface LockResult {
25
+ acquired: boolean;
26
+ error: string | null;
27
+ existingLock: LockMetadata | null;
28
+ isStale: boolean;
29
+ }
30
+ interface UnlockResult {
31
+ released: boolean;
32
+ error: string | null;
33
+ notFound: boolean;
34
+ }
35
+ interface AuditedUnlockResult extends UnlockResult {
36
+ reason?: string;
37
+ forced?: boolean;
38
+ previousLock?: LockMetadata | null;
39
+ }
40
+ interface AcquireLockOptions {
41
+ agentSession?: string | null;
42
+ baseDir?: string | null;
43
+ }
44
+ interface ReleaseLockOptions {
45
+ wuId?: string | null;
46
+ baseDir?: string | null;
47
+ force?: boolean;
48
+ }
49
+ interface CheckLockOptions {
50
+ baseDir?: string | null;
51
+ }
52
+ interface AuditedUnlockOptions {
53
+ reason: string;
54
+ baseDir?: string | null;
55
+ force?: boolean;
56
+ }
57
+ /**
58
+ * Get the stale lock threshold in milliseconds.
59
+ *
60
+ * WU-1949: Default is 2 hours. Can be overridden via STALE_LOCK_THRESHOLD_HOURS env var.
61
+ *
62
+ * @returns {number} Threshold in milliseconds
63
+ */
64
+ export declare function getStaleThresholdMs(): number;
65
+ /**
66
+ * @typedef {Object} LockMetadata
67
+ * @property {string} wuId - WU ID that holds the lock (e.g., "WU-123")
68
+ * @property {string} timestamp - ISO timestamp when lock was acquired
69
+ * @property {string|null} agentSession - Agent session ID if available
70
+ * @property {number} pid - Process ID that acquired the lock
71
+ * @property {string} lane - Original lane name
72
+ */
73
+ /**
74
+ * @typedef {Object} LockResult
75
+ * @property {boolean} acquired - Whether lock was successfully acquired
76
+ * @property {string|null} error - Error message if acquisition failed
77
+ * @property {LockMetadata|null} existingLock - Existing lock metadata if lock already exists
78
+ * @property {boolean} isStale - Whether the existing lock is stale (>2h old by default)
79
+ */
80
+ /**
81
+ * @typedef {Object} UnlockResult
82
+ * @property {boolean} released - Whether lock was successfully released
83
+ * @property {string|null} error - Error message if release failed
84
+ * @property {boolean} notFound - Whether lock file was not found
85
+ */
86
+ /**
87
+ * Get the path to the locks directory
88
+ * @param {string} [baseDir] - Optional base directory (defaults to project root)
89
+ * @returns {string} Absolute path to locks directory
90
+ */
91
+ export declare function getLocksDir(baseDir?: string | null): string;
92
+ /**
93
+ * Get the path to a lock file for a specific lane
94
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
95
+ * @param {string} [baseDir] - Optional base directory
96
+ * @returns {string} Absolute path to lock file
97
+ */
98
+ export declare function getLockFilePath(lane: string, baseDir?: string | null): string;
99
+ /**
100
+ * Check if a lock is stale (>2 hours old by default, configurable via env var)
101
+ *
102
+ * WU-1949: Reduced threshold from 24h to 2h. Zombie detection (PID check)
103
+ * remains the fast-path for immediate recovery when process has exited.
104
+ *
105
+ * @param {LockMetadata} metadata - Lock metadata
106
+ * @returns {boolean} True if lock is stale
107
+ */
108
+ export declare function isLockStale(metadata: LockMetadata | null): boolean;
109
+ /**
110
+ * WU-1808: Check if a lock is a "zombie" (PID no longer running)
111
+ *
112
+ * A zombie lock occurs when the process that acquired the lock has crashed
113
+ * or exited without releasing it. This function checks if the process
114
+ * identified by the lock's PID is still running.
115
+ *
116
+ * @param {LockMetadata} metadata - Lock metadata
117
+ * @returns {boolean} True if lock is a zombie (PID not running)
118
+ */
119
+ export declare function isZombieLock(metadata: LockMetadata | null): boolean;
120
+ /**
121
+ * Read lock metadata from a lock file
122
+ * @param {string} lockPath - Path to lock file
123
+ * @returns {LockMetadata|null} Lock metadata or null if file doesn't exist/is invalid
124
+ */
125
+ export declare function readLockMetadata(lockPath: string): LockMetadata | null;
126
+ /**
127
+ * Acquire a lane lock atomically
128
+ *
129
+ * Uses the 'wx' flag for atomic file creation - fails if file already exists.
130
+ * This prevents TOCTOU race conditions where multiple agents could both
131
+ * read an empty lock state before either writes.
132
+ *
133
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
134
+ * @param {string} wuId - WU ID being claimed (e.g., "WU-123")
135
+ * @param {Object} [options] - Optional parameters
136
+ * @param {string} [options.agentSession] - Agent session ID
137
+ * @param {string} [options.baseDir] - Base directory for lock files
138
+ * @returns {LockResult} Result of lock acquisition attempt
139
+ */
140
+ export declare function acquireLaneLock(lane: string, wuId: string, options?: AcquireLockOptions): LockResult;
141
+ /**
142
+ * Release a lane lock
143
+ *
144
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
145
+ * @param {Object} [options] - Optional parameters
146
+ * @param {string} [options.wuId] - WU ID to validate ownership (optional)
147
+ * @param {string} [options.baseDir] - Base directory for lock files
148
+ * @param {boolean} [options.force] - Force release even if not owner
149
+ * @returns {UnlockResult} Result of lock release attempt
150
+ */
151
+ export declare function releaseLaneLock(lane: string, options?: ReleaseLockOptions): UnlockResult;
152
+ /**
153
+ * Check if a lane is currently locked
154
+ *
155
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
156
+ * @param {Object} [options] - Optional parameters
157
+ * @param {string} [options.baseDir] - Base directory for lock files
158
+ * @returns {{ locked: boolean, metadata: LockMetadata|null, isStale: boolean }}
159
+ */
160
+ export declare function checkLaneLock(lane: string, options?: CheckLockOptions): {
161
+ locked: boolean;
162
+ metadata: LockMetadata | null;
163
+ isStale: boolean;
164
+ };
165
+ /**
166
+ * Force-remove a stale lock with warning
167
+ *
168
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
169
+ * @param {Object} [options] - Optional parameters
170
+ * @param {string} [options.baseDir] - Base directory for lock files
171
+ * @returns {UnlockResult} Result of forced removal
172
+ */
173
+ export declare function forceRemoveStaleLock(lane: string, options?: CheckLockOptions): UnlockResult;
174
+ /**
175
+ * Get all current lane locks
176
+ *
177
+ * @param {Object} [options] - Optional parameters
178
+ * @param {string} [options.baseDir] - Base directory for lock files
179
+ * @returns {Map<string, LockMetadata>} Map of lane name to lock metadata
180
+ */
181
+ export declare function getAllLaneLocks(options?: CheckLockOptions): Map<string, LockMetadata>;
182
+ /**
183
+ * @typedef {Object} AuditedUnlockResult
184
+ * @property {boolean} released - Whether lock was successfully released
185
+ * @property {string|null} error - Error message if release failed
186
+ * @property {boolean} notFound - Whether lock file was not found
187
+ * @property {string} [reason] - The provided reason for unlocking
188
+ * @property {boolean} [forced] - Whether --force was used
189
+ * @property {LockMetadata|null} [previousLock] - Metadata of the removed lock
190
+ */
191
+ /**
192
+ * WU-1808: Audited unlock command for operators to safely clear lane locks
193
+ *
194
+ * This function provides a dedicated command for operators to clear locks with
195
+ * proper audit logging. It follows a safety-first approach:
196
+ *
197
+ * - Zombie locks (PID not running): Can be unlocked without --force
198
+ * - Stale locks (>2h old by default): Can be unlocked without --force
199
+ * - Active locks (recent, PID running): Require --force to unlock
200
+ *
201
+ * All unlocks require a reason parameter for audit purposes.
202
+ *
203
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
204
+ * @param {Object} options - Required options
205
+ * @param {string} options.reason - Reason for unlocking (required for audit)
206
+ * @param {string} [options.baseDir] - Base directory for lock files
207
+ * @param {boolean} [options.force] - Force unlock even if lock is active
208
+ * @returns {AuditedUnlockResult} Result of audited unlock attempt
209
+ */
210
+ export declare function auditedUnlock(lane: string, options: AuditedUnlockOptions): AuditedUnlockResult;
211
+ export {};
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Lane Lock Module
4
+ *
5
+ * Provides atomic file-based locking to prevent TOCTOU (Time Of Check To Time Of Use)
6
+ * race conditions when multiple agents try to claim WUs in the same lane simultaneously.
7
+ *
8
+ * The lock file is created atomically using the 'wx' flag (exclusive create),
9
+ * which fails if the file already exists. This prevents the race condition where
10
+ * parallel agents could both read status.md before either updates it.
11
+ *
12
+ * Lock file location: .beacon/locks/<lane-kebab>.lock
13
+ * Lock file format: JSON with wuId, timestamp, agentSession, pid
14
+ *
15
+ * @see WU-1603 - Race condition fix for wu:claim
16
+ */
17
+ import { openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync, } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { toKebab } from './wu-constants.js';
20
+ import { getProjectRoot } from './wu-constants.js';
21
+ /** Log prefix for lane-lock messages */
22
+ const LOG_PREFIX = '[lane-lock]';
23
+ /** Directory for lock files relative to project root */
24
+ const LOCKS_DIR = '.beacon/locks';
25
+ /** Default stale lock threshold in hours (WU-1949: reduced from 24h to 2h) */
26
+ const DEFAULT_STALE_LOCK_THRESHOLD_HOURS = 2;
27
+ /**
28
+ * Get the stale lock threshold in milliseconds.
29
+ *
30
+ * WU-1949: Default is 2 hours. Can be overridden via STALE_LOCK_THRESHOLD_HOURS env var.
31
+ *
32
+ * @returns {number} Threshold in milliseconds
33
+ */
34
+ export function getStaleThresholdMs() {
35
+ const envValue = process.env.STALE_LOCK_THRESHOLD_HOURS;
36
+ if (envValue) {
37
+ const hours = parseFloat(envValue);
38
+ if (!Number.isNaN(hours) && hours > 0) {
39
+ return hours * 60 * 60 * 1000;
40
+ }
41
+ }
42
+ return DEFAULT_STALE_LOCK_THRESHOLD_HOURS * 60 * 60 * 1000;
43
+ }
44
+ /**
45
+ * @typedef {Object} LockMetadata
46
+ * @property {string} wuId - WU ID that holds the lock (e.g., "WU-123")
47
+ * @property {string} timestamp - ISO timestamp when lock was acquired
48
+ * @property {string|null} agentSession - Agent session ID if available
49
+ * @property {number} pid - Process ID that acquired the lock
50
+ * @property {string} lane - Original lane name
51
+ */
52
+ /**
53
+ * @typedef {Object} LockResult
54
+ * @property {boolean} acquired - Whether lock was successfully acquired
55
+ * @property {string|null} error - Error message if acquisition failed
56
+ * @property {LockMetadata|null} existingLock - Existing lock metadata if lock already exists
57
+ * @property {boolean} isStale - Whether the existing lock is stale (>2h old by default)
58
+ */
59
+ /**
60
+ * @typedef {Object} UnlockResult
61
+ * @property {boolean} released - Whether lock was successfully released
62
+ * @property {string|null} error - Error message if release failed
63
+ * @property {boolean} notFound - Whether lock file was not found
64
+ */
65
+ /**
66
+ * Get the path to the locks directory
67
+ * @param {string} [baseDir] - Optional base directory (defaults to project root)
68
+ * @returns {string} Absolute path to locks directory
69
+ */
70
+ export function getLocksDir(baseDir = null) {
71
+ const projectRoot = baseDir || getProjectRoot(import.meta.url);
72
+ return path.join(projectRoot, LOCKS_DIR);
73
+ }
74
+ /**
75
+ * Get the path to a lock file for a specific lane
76
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
77
+ * @param {string} [baseDir] - Optional base directory
78
+ * @returns {string} Absolute path to lock file
79
+ */
80
+ export function getLockFilePath(lane, baseDir = null) {
81
+ const laneKebab = toKebab(lane);
82
+ const locksDir = getLocksDir(baseDir);
83
+ return path.join(locksDir, `${laneKebab}.lock`);
84
+ }
85
+ /**
86
+ * Ensure the locks directory exists
87
+ * @param {string} [baseDir] - Optional base directory
88
+ */
89
+ function ensureLocksDir(baseDir = null) {
90
+ const locksDir = getLocksDir(baseDir);
91
+ if (!existsSync(locksDir)) {
92
+ mkdirSync(locksDir, { recursive: true });
93
+ }
94
+ }
95
+ /**
96
+ * Check if a lock is stale (>2 hours old by default, configurable via env var)
97
+ *
98
+ * WU-1949: Reduced threshold from 24h to 2h. Zombie detection (PID check)
99
+ * remains the fast-path for immediate recovery when process has exited.
100
+ *
101
+ * @param {LockMetadata} metadata - Lock metadata
102
+ * @returns {boolean} True if lock is stale
103
+ */
104
+ export function isLockStale(metadata) {
105
+ if (!metadata || !metadata.timestamp) {
106
+ return true; // Invalid metadata is considered stale
107
+ }
108
+ const lockTime = new Date(metadata.timestamp).getTime();
109
+ const now = Date.now();
110
+ return now - lockTime > getStaleThresholdMs();
111
+ }
112
+ /**
113
+ * WU-1808: Check if a lock is a "zombie" (PID no longer running)
114
+ *
115
+ * A zombie lock occurs when the process that acquired the lock has crashed
116
+ * or exited without releasing it. This function checks if the process
117
+ * identified by the lock's PID is still running.
118
+ *
119
+ * @param {LockMetadata} metadata - Lock metadata
120
+ * @returns {boolean} True if lock is a zombie (PID not running)
121
+ */
122
+ export function isZombieLock(metadata) {
123
+ if (!metadata || typeof metadata.pid !== 'number') {
124
+ return true; // Invalid metadata is considered zombie
125
+ }
126
+ // Check if process is running by sending signal 0
127
+ // This doesn't actually send a signal, but checks if the process exists
128
+ try {
129
+ process.kill(metadata.pid, 0);
130
+ return false; // Process exists
131
+ }
132
+ catch {
133
+ // ESRCH = no such process (zombie)
134
+ // EPERM = process exists but we don't have permission (not zombie)
135
+ return true;
136
+ }
137
+ }
138
+ /**
139
+ * Read lock metadata from a lock file
140
+ * @param {string} lockPath - Path to lock file
141
+ * @returns {LockMetadata|null} Lock metadata or null if file doesn't exist/is invalid
142
+ */
143
+ export function readLockMetadata(lockPath) {
144
+ try {
145
+ if (!existsSync(lockPath)) {
146
+ return null;
147
+ }
148
+ const content = readFileSync(lockPath, { encoding: 'utf-8' });
149
+ return JSON.parse(content);
150
+ }
151
+ catch {
152
+ // Invalid JSON or read error - treat as no lock
153
+ return null;
154
+ }
155
+ }
156
+ /**
157
+ * Acquire a lane lock atomically
158
+ *
159
+ * Uses the 'wx' flag for atomic file creation - fails if file already exists.
160
+ * This prevents TOCTOU race conditions where multiple agents could both
161
+ * read an empty lock state before either writes.
162
+ *
163
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
164
+ * @param {string} wuId - WU ID being claimed (e.g., "WU-123")
165
+ * @param {Object} [options] - Optional parameters
166
+ * @param {string} [options.agentSession] - Agent session ID
167
+ * @param {string} [options.baseDir] - Base directory for lock files
168
+ * @returns {LockResult} Result of lock acquisition attempt
169
+ */
170
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- WU-1808: Added zombie lock detection increases complexity but all paths are necessary
171
+ export function acquireLaneLock(lane, wuId, options = {}) {
172
+ const { agentSession = null, baseDir = null } = options;
173
+ try {
174
+ ensureLocksDir(baseDir);
175
+ const lockPath = getLockFilePath(lane, baseDir);
176
+ // Build lock metadata
177
+ const metadata = {
178
+ wuId,
179
+ timestamp: new Date().toISOString(),
180
+ agentSession,
181
+ pid: process.pid,
182
+ lane,
183
+ };
184
+ try {
185
+ // Attempt atomic file creation with 'wx' flag
186
+ // 'wx' = write exclusive - fails if file exists
187
+ const fd = openSync(lockPath, 'wx');
188
+ // Write metadata and close
189
+ writeFileSync(lockPath, JSON.stringify(metadata, null, 2), { encoding: 'utf-8' });
190
+ closeSync(fd);
191
+ console.log(`${LOG_PREFIX} Acquired lane lock for "${lane}" (${wuId})`);
192
+ return {
193
+ acquired: true,
194
+ error: null,
195
+ existingLock: null,
196
+ isStale: false,
197
+ };
198
+ }
199
+ catch (err) {
200
+ // File already exists - check if it's our lock or another agent's
201
+ if (err.code === 'EEXIST') {
202
+ const existingLock = readLockMetadata(lockPath);
203
+ const stale = existingLock ? isLockStale(existingLock) : true;
204
+ const zombie = existingLock ? isZombieLock(existingLock) : true;
205
+ // Check if it's the same WU (re-claim attempt)
206
+ if (existingLock && existingLock.wuId === wuId) {
207
+ console.log(`${LOG_PREFIX} Lock already held by same WU (${wuId})`);
208
+ return {
209
+ acquired: true, // Allow re-claim of same WU
210
+ error: null,
211
+ existingLock,
212
+ isStale: stale,
213
+ };
214
+ }
215
+ // WU-1808: Auto-clear zombie locks (PID no longer running)
216
+ // This allows recovery from crashed wu:claim processes
217
+ if (zombie) {
218
+ console.warn(`${LOG_PREFIX} Detected zombie lock for "${lane}" (PID ${existingLock?.pid} not running)`);
219
+ console.warn(`${LOG_PREFIX} Previous owner: ${existingLock?.wuId}`);
220
+ console.warn(`${LOG_PREFIX} Lock timestamp: ${existingLock?.timestamp}`);
221
+ console.warn(`${LOG_PREFIX} Auto-clearing zombie lock...`);
222
+ // Remove the zombie lock
223
+ try {
224
+ unlinkSync(lockPath);
225
+ }
226
+ catch {
227
+ // Ignore errors - file might have been removed by another process
228
+ }
229
+ // Retry acquisition
230
+ return acquireLaneLock(lane, wuId, options);
231
+ }
232
+ return {
233
+ acquired: false,
234
+ error: existingLock
235
+ ? `Lane "${lane}" is locked by ${existingLock.wuId} (since ${existingLock.timestamp})`
236
+ : `Lane "${lane}" has an invalid lock file`,
237
+ existingLock,
238
+ isStale: stale,
239
+ };
240
+ }
241
+ // Other error (permissions, etc.)
242
+ throw err;
243
+ }
244
+ }
245
+ catch (err) {
246
+ const errMessage = err instanceof Error ? err.message : String(err);
247
+ return {
248
+ acquired: false,
249
+ error: `Failed to acquire lane lock: ${errMessage}`,
250
+ existingLock: null,
251
+ isStale: false,
252
+ };
253
+ }
254
+ }
255
+ /**
256
+ * Release a lane lock
257
+ *
258
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
259
+ * @param {Object} [options] - Optional parameters
260
+ * @param {string} [options.wuId] - WU ID to validate ownership (optional)
261
+ * @param {string} [options.baseDir] - Base directory for lock files
262
+ * @param {boolean} [options.force] - Force release even if not owner
263
+ * @returns {UnlockResult} Result of lock release attempt
264
+ */
265
+ export function releaseLaneLock(lane, options = {}) {
266
+ const { wuId = null, baseDir = null, force = false } = options;
267
+ try {
268
+ const lockPath = getLockFilePath(lane, baseDir);
269
+ if (!existsSync(lockPath)) {
270
+ // Lock doesn't exist - not an error, just nothing to release
271
+ return {
272
+ released: true,
273
+ error: null,
274
+ notFound: true,
275
+ };
276
+ }
277
+ // Validate ownership if wuId provided
278
+ if (wuId && !force) {
279
+ const existingLock = readLockMetadata(lockPath);
280
+ if (existingLock && existingLock.wuId !== wuId) {
281
+ return {
282
+ released: false,
283
+ error: `Cannot release lock: owned by ${existingLock.wuId}, not ${wuId}`,
284
+ notFound: false,
285
+ };
286
+ }
287
+ }
288
+ // Remove the lock file
289
+ unlinkSync(lockPath);
290
+ console.log(`${LOG_PREFIX} Released lane lock for "${lane}"`);
291
+ return {
292
+ released: true,
293
+ error: null,
294
+ notFound: false,
295
+ };
296
+ }
297
+ catch (err) {
298
+ const errMessage = err instanceof Error ? err.message : String(err);
299
+ return {
300
+ released: false,
301
+ error: `Failed to release lane lock: ${errMessage}`,
302
+ notFound: false,
303
+ };
304
+ }
305
+ }
306
+ /**
307
+ * Check if a lane is currently locked
308
+ *
309
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
310
+ * @param {Object} [options] - Optional parameters
311
+ * @param {string} [options.baseDir] - Base directory for lock files
312
+ * @returns {{ locked: boolean, metadata: LockMetadata|null, isStale: boolean }}
313
+ */
314
+ export function checkLaneLock(lane, options = {}) {
315
+ const { baseDir = null } = options;
316
+ const lockPath = getLockFilePath(lane, baseDir);
317
+ const metadata = readLockMetadata(lockPath);
318
+ if (!metadata) {
319
+ return {
320
+ locked: false,
321
+ metadata: null,
322
+ isStale: false,
323
+ };
324
+ }
325
+ return {
326
+ locked: true,
327
+ metadata,
328
+ isStale: isLockStale(metadata),
329
+ };
330
+ }
331
+ /**
332
+ * Force-remove a stale lock with warning
333
+ *
334
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
335
+ * @param {Object} [options] - Optional parameters
336
+ * @param {string} [options.baseDir] - Base directory for lock files
337
+ * @returns {UnlockResult} Result of forced removal
338
+ */
339
+ export function forceRemoveStaleLock(lane, options = {}) {
340
+ const { baseDir = null } = options;
341
+ const lockPath = getLockFilePath(lane, baseDir);
342
+ const existingLock = readLockMetadata(lockPath);
343
+ if (!existingLock) {
344
+ return {
345
+ released: true,
346
+ error: null,
347
+ notFound: true,
348
+ };
349
+ }
350
+ if (!isLockStale(existingLock)) {
351
+ return {
352
+ released: false,
353
+ error: `Cannot force-remove: lock is not stale (${existingLock.wuId} since ${existingLock.timestamp})`,
354
+ notFound: false,
355
+ };
356
+ }
357
+ console.warn(`${LOG_PREFIX} ⚠️ Force-removing stale lock for "${lane}"`);
358
+ console.warn(`${LOG_PREFIX} Previous owner: ${existingLock.wuId}`);
359
+ console.warn(`${LOG_PREFIX} Lock timestamp: ${existingLock.timestamp}`);
360
+ return releaseLaneLock(lane, { baseDir, force: true });
361
+ }
362
+ /**
363
+ * Get all current lane locks
364
+ *
365
+ * @param {Object} [options] - Optional parameters
366
+ * @param {string} [options.baseDir] - Base directory for lock files
367
+ * @returns {Map<string, LockMetadata>} Map of lane name to lock metadata
368
+ */
369
+ export function getAllLaneLocks(options = {}) {
370
+ const { baseDir = null } = options;
371
+ const locksDir = getLocksDir(baseDir);
372
+ const locks = new Map();
373
+ if (!existsSync(locksDir)) {
374
+ return locks;
375
+ }
376
+ try {
377
+ const files = require('node:fs').readdirSync(locksDir);
378
+ for (const file of files) {
379
+ if (!file.endsWith('.lock'))
380
+ continue;
381
+ const lockPath = path.join(locksDir, file);
382
+ const metadata = readLockMetadata(lockPath);
383
+ if (metadata && metadata.lane) {
384
+ locks.set(metadata.lane, metadata);
385
+ }
386
+ }
387
+ }
388
+ catch {
389
+ // Ignore errors reading directory
390
+ }
391
+ return locks;
392
+ }
393
+ /**
394
+ * @typedef {Object} AuditedUnlockResult
395
+ * @property {boolean} released - Whether lock was successfully released
396
+ * @property {string|null} error - Error message if release failed
397
+ * @property {boolean} notFound - Whether lock file was not found
398
+ * @property {string} [reason] - The provided reason for unlocking
399
+ * @property {boolean} [forced] - Whether --force was used
400
+ * @property {LockMetadata|null} [previousLock] - Metadata of the removed lock
401
+ */
402
+ /**
403
+ * WU-1808: Audited unlock command for operators to safely clear lane locks
404
+ *
405
+ * This function provides a dedicated command for operators to clear locks with
406
+ * proper audit logging. It follows a safety-first approach:
407
+ *
408
+ * - Zombie locks (PID not running): Can be unlocked without --force
409
+ * - Stale locks (>2h old by default): Can be unlocked without --force
410
+ * - Active locks (recent, PID running): Require --force to unlock
411
+ *
412
+ * All unlocks require a reason parameter for audit purposes.
413
+ *
414
+ * @param {string} lane - Lane name (e.g., "Operations: Tooling")
415
+ * @param {Object} options - Required options
416
+ * @param {string} options.reason - Reason for unlocking (required for audit)
417
+ * @param {string} [options.baseDir] - Base directory for lock files
418
+ * @param {boolean} [options.force] - Force unlock even if lock is active
419
+ * @returns {AuditedUnlockResult} Result of audited unlock attempt
420
+ */
421
+ export function auditedUnlock(lane, options) {
422
+ const { reason, baseDir = null, force = false } = options;
423
+ // Require reason for audit trail
424
+ if (!reason) {
425
+ return {
426
+ released: false,
427
+ error: 'Reason is required for audited unlock. Use --reason "<text>"',
428
+ notFound: false,
429
+ };
430
+ }
431
+ const lockPath = getLockFilePath(lane, baseDir);
432
+ const existingLock = readLockMetadata(lockPath);
433
+ // Handle non-existent lock
434
+ if (!existingLock) {
435
+ return {
436
+ released: true,
437
+ error: null,
438
+ notFound: true,
439
+ reason,
440
+ };
441
+ }
442
+ const stale = isLockStale(existingLock);
443
+ const zombie = isZombieLock(existingLock);
444
+ const safeToRemove = stale || zombie;
445
+ // If lock is active (not stale, not zombie), require --force
446
+ if (!safeToRemove && !force) {
447
+ return {
448
+ released: false,
449
+ error: `Cannot unlock active lock for "${lane}" (${existingLock.wuId}).\n` +
450
+ `Lock is recent (${existingLock.timestamp}) and PID ${existingLock.pid} is running.\n` +
451
+ `Use --force to override (emergency only).`,
452
+ notFound: false,
453
+ previousLock: existingLock,
454
+ };
455
+ }
456
+ // Log the unlock for audit purposes
457
+ const unlockType = force ? 'FORCED' : zombie ? 'ZOMBIE' : 'STALE';
458
+ console.log(`${LOG_PREFIX} Audited unlock (${unlockType}) for "${lane}"`);
459
+ console.log(`${LOG_PREFIX} Previous owner: ${existingLock.wuId}`);
460
+ console.log(`${LOG_PREFIX} Lock timestamp: ${existingLock.timestamp}`);
461
+ console.log(`${LOG_PREFIX} Lock PID: ${existingLock.pid}`);
462
+ console.log(`${LOG_PREFIX} Reason: ${reason}`);
463
+ if (force && !safeToRemove) {
464
+ console.warn(`${LOG_PREFIX} ⚠️ WARNING: Forced unlock of active lock!`);
465
+ }
466
+ // Release the lock
467
+ const releaseResult = releaseLaneLock(lane, { baseDir, force: true });
468
+ return {
469
+ ...releaseResult,
470
+ reason,
471
+ forced: force && !safeToRemove,
472
+ previousLock: existingLock,
473
+ };
474
+ }