wogiflow 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 (221) hide show
  1. package/.workflow/agents/reviewer.md +81 -0
  2. package/.workflow/agents/security.md +94 -0
  3. package/.workflow/agents/story-writer.md +58 -0
  4. package/.workflow/bridges/base-bridge.js +395 -0
  5. package/.workflow/bridges/claude-bridge.js +434 -0
  6. package/.workflow/bridges/index.js +130 -0
  7. package/.workflow/lib/assumption-detector.js +481 -0
  8. package/.workflow/lib/config-substitution.js +371 -0
  9. package/.workflow/lib/failure-categories.js +478 -0
  10. package/.workflow/state/app-map.md.template +15 -0
  11. package/.workflow/state/architecture.md.template +24 -0
  12. package/.workflow/state/component-index.json.template +5 -0
  13. package/.workflow/state/decisions.md.template +15 -0
  14. package/.workflow/state/feedback-patterns.md.template +9 -0
  15. package/.workflow/state/knowledge-sync.json.template +6 -0
  16. package/.workflow/state/progress.md.template +14 -0
  17. package/.workflow/state/ready.json.template +7 -0
  18. package/.workflow/state/request-log.md.template +14 -0
  19. package/.workflow/state/session-state.json.template +11 -0
  20. package/.workflow/state/stack.md.template +33 -0
  21. package/.workflow/state/testing.md.template +36 -0
  22. package/.workflow/templates/claude-md.hbs +257 -0
  23. package/.workflow/templates/correction-report.md +67 -0
  24. package/.workflow/templates/gemini-md.hbs +52 -0
  25. package/README.md +1802 -0
  26. package/bin/flow +205 -0
  27. package/lib/index.js +33 -0
  28. package/lib/installer.js +467 -0
  29. package/lib/release-channel.js +269 -0
  30. package/lib/skill-registry.js +526 -0
  31. package/lib/upgrader.js +401 -0
  32. package/lib/utils.js +305 -0
  33. package/package.json +64 -0
  34. package/scripts/flow +985 -0
  35. package/scripts/flow-adaptive-learning.js +1259 -0
  36. package/scripts/flow-aggregate.js +488 -0
  37. package/scripts/flow-archive +133 -0
  38. package/scripts/flow-auto-context.js +1015 -0
  39. package/scripts/flow-auto-learn.js +615 -0
  40. package/scripts/flow-bridge.js +223 -0
  41. package/scripts/flow-browser-suggest.js +316 -0
  42. package/scripts/flow-bug.js +247 -0
  43. package/scripts/flow-cascade.js +711 -0
  44. package/scripts/flow-changelog +85 -0
  45. package/scripts/flow-checkpoint.js +483 -0
  46. package/scripts/flow-cli.js +403 -0
  47. package/scripts/flow-code-intelligence.js +760 -0
  48. package/scripts/flow-complexity.js +502 -0
  49. package/scripts/flow-config-set.js +152 -0
  50. package/scripts/flow-constants.js +157 -0
  51. package/scripts/flow-context +152 -0
  52. package/scripts/flow-context-init.js +482 -0
  53. package/scripts/flow-context-monitor.js +384 -0
  54. package/scripts/flow-context-scoring.js +886 -0
  55. package/scripts/flow-correct.js +458 -0
  56. package/scripts/flow-damage-control.js +985 -0
  57. package/scripts/flow-deps +101 -0
  58. package/scripts/flow-diff.js +700 -0
  59. package/scripts/flow-done +151 -0
  60. package/scripts/flow-done.js +489 -0
  61. package/scripts/flow-durable-session.js +1541 -0
  62. package/scripts/flow-entropy-monitor.js +345 -0
  63. package/scripts/flow-export-profile +349 -0
  64. package/scripts/flow-export-scanner.js +1046 -0
  65. package/scripts/flow-figma-confirm.js +400 -0
  66. package/scripts/flow-figma-extract.js +496 -0
  67. package/scripts/flow-figma-generate.js +683 -0
  68. package/scripts/flow-figma-index.js +909 -0
  69. package/scripts/flow-figma-match.js +617 -0
  70. package/scripts/flow-figma-mcp-server.js +518 -0
  71. package/scripts/flow-figma-pipeline.js +414 -0
  72. package/scripts/flow-file-ops.js +301 -0
  73. package/scripts/flow-gate-confidence.js +825 -0
  74. package/scripts/flow-guided-edit.js +659 -0
  75. package/scripts/flow-health +185 -0
  76. package/scripts/flow-health.js +413 -0
  77. package/scripts/flow-hooks.js +556 -0
  78. package/scripts/flow-http-client.js +249 -0
  79. package/scripts/flow-hybrid-detect.js +167 -0
  80. package/scripts/flow-hybrid-interactive.js +591 -0
  81. package/scripts/flow-hybrid-test.js +152 -0
  82. package/scripts/flow-import-profile +439 -0
  83. package/scripts/flow-init +253 -0
  84. package/scripts/flow-instruction-richness.js +827 -0
  85. package/scripts/flow-jira-integration.js +579 -0
  86. package/scripts/flow-knowledge-router.js +522 -0
  87. package/scripts/flow-knowledge-sync.js +589 -0
  88. package/scripts/flow-linear-integration.js +631 -0
  89. package/scripts/flow-links.js +774 -0
  90. package/scripts/flow-log-manager.js +559 -0
  91. package/scripts/flow-loop-enforcer.js +1246 -0
  92. package/scripts/flow-loop-retry-learning.js +630 -0
  93. package/scripts/flow-lsp.js +923 -0
  94. package/scripts/flow-map-index +348 -0
  95. package/scripts/flow-map-sync +201 -0
  96. package/scripts/flow-memory-blocks.js +668 -0
  97. package/scripts/flow-memory-compactor.js +350 -0
  98. package/scripts/flow-memory-db.js +1110 -0
  99. package/scripts/flow-memory-sync.js +484 -0
  100. package/scripts/flow-metrics.js +353 -0
  101. package/scripts/flow-migrate-ids.js +370 -0
  102. package/scripts/flow-model-adapter.js +802 -0
  103. package/scripts/flow-model-router.js +884 -0
  104. package/scripts/flow-models.js +1231 -0
  105. package/scripts/flow-morning.js +517 -0
  106. package/scripts/flow-multi-approach.js +660 -0
  107. package/scripts/flow-new-feature +86 -0
  108. package/scripts/flow-onboard +1042 -0
  109. package/scripts/flow-orchestrate-llm.js +459 -0
  110. package/scripts/flow-orchestrate.js +3592 -0
  111. package/scripts/flow-output.js +123 -0
  112. package/scripts/flow-parallel-detector.js +399 -0
  113. package/scripts/flow-parallel-dispatch.js +987 -0
  114. package/scripts/flow-parallel.js +428 -0
  115. package/scripts/flow-pattern-enforcer.js +600 -0
  116. package/scripts/flow-prd-manager.js +282 -0
  117. package/scripts/flow-progress.js +323 -0
  118. package/scripts/flow-project-analyzer.js +975 -0
  119. package/scripts/flow-prompt-composer.js +487 -0
  120. package/scripts/flow-providers.js +1381 -0
  121. package/scripts/flow-queue.js +308 -0
  122. package/scripts/flow-ready +82 -0
  123. package/scripts/flow-ready.js +189 -0
  124. package/scripts/flow-regression.js +396 -0
  125. package/scripts/flow-response-parser.js +450 -0
  126. package/scripts/flow-resume.js +284 -0
  127. package/scripts/flow-rules-sync.js +439 -0
  128. package/scripts/flow-run-trace.js +718 -0
  129. package/scripts/flow-safety.js +587 -0
  130. package/scripts/flow-search +104 -0
  131. package/scripts/flow-security.js +481 -0
  132. package/scripts/flow-session-end +106 -0
  133. package/scripts/flow-session-end.js +437 -0
  134. package/scripts/flow-session-state.js +671 -0
  135. package/scripts/flow-setup-hooks +216 -0
  136. package/scripts/flow-setup-hooks.js +377 -0
  137. package/scripts/flow-skill-create.js +329 -0
  138. package/scripts/flow-skill-creator.js +572 -0
  139. package/scripts/flow-skill-generator.js +1046 -0
  140. package/scripts/flow-skill-learn.js +880 -0
  141. package/scripts/flow-skill-matcher.js +578 -0
  142. package/scripts/flow-spec-generator.js +820 -0
  143. package/scripts/flow-stack-wizard.js +895 -0
  144. package/scripts/flow-standup +162 -0
  145. package/scripts/flow-start +74 -0
  146. package/scripts/flow-start.js +235 -0
  147. package/scripts/flow-status +110 -0
  148. package/scripts/flow-status.js +301 -0
  149. package/scripts/flow-step-browser.js +83 -0
  150. package/scripts/flow-step-changelog.js +217 -0
  151. package/scripts/flow-step-comments.js +306 -0
  152. package/scripts/flow-step-complexity.js +234 -0
  153. package/scripts/flow-step-coverage.js +218 -0
  154. package/scripts/flow-step-knowledge.js +193 -0
  155. package/scripts/flow-step-pr-tests.js +364 -0
  156. package/scripts/flow-step-regression.js +89 -0
  157. package/scripts/flow-step-review.js +516 -0
  158. package/scripts/flow-step-security.js +162 -0
  159. package/scripts/flow-step-silent-failures.js +290 -0
  160. package/scripts/flow-step-simplifier.js +346 -0
  161. package/scripts/flow-story +105 -0
  162. package/scripts/flow-story.js +500 -0
  163. package/scripts/flow-suspend.js +252 -0
  164. package/scripts/flow-sync-daemon.js +654 -0
  165. package/scripts/flow-task-analyzer.js +606 -0
  166. package/scripts/flow-team-dashboard.js +748 -0
  167. package/scripts/flow-team-sync.js +752 -0
  168. package/scripts/flow-team.js +977 -0
  169. package/scripts/flow-tech-options.js +528 -0
  170. package/scripts/flow-templates.js +812 -0
  171. package/scripts/flow-tiered-learning.js +728 -0
  172. package/scripts/flow-trace +204 -0
  173. package/scripts/flow-transcript-chunking.js +1106 -0
  174. package/scripts/flow-transcript-digest.js +7918 -0
  175. package/scripts/flow-transcript-language.js +465 -0
  176. package/scripts/flow-transcript-parsing.js +1085 -0
  177. package/scripts/flow-transcript-stories.js +2194 -0
  178. package/scripts/flow-update-map +224 -0
  179. package/scripts/flow-utils.js +2242 -0
  180. package/scripts/flow-verification.js +644 -0
  181. package/scripts/flow-verify.js +1177 -0
  182. package/scripts/flow-voice-input.js +638 -0
  183. package/scripts/flow-watch +168 -0
  184. package/scripts/flow-workflow-steps.js +521 -0
  185. package/scripts/flow-workflow.js +1029 -0
  186. package/scripts/flow-worktree.js +489 -0
  187. package/scripts/hooks/adapters/base-adapter.js +102 -0
  188. package/scripts/hooks/adapters/claude-code.js +359 -0
  189. package/scripts/hooks/adapters/index.js +79 -0
  190. package/scripts/hooks/core/component-check.js +341 -0
  191. package/scripts/hooks/core/index.js +35 -0
  192. package/scripts/hooks/core/loop-check.js +241 -0
  193. package/scripts/hooks/core/session-context.js +294 -0
  194. package/scripts/hooks/core/task-gate.js +177 -0
  195. package/scripts/hooks/core/validation.js +230 -0
  196. package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
  197. package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
  198. package/scripts/hooks/entry/claude-code/session-end.js +87 -0
  199. package/scripts/hooks/entry/claude-code/session-start.js +46 -0
  200. package/scripts/hooks/entry/claude-code/stop.js +43 -0
  201. package/scripts/postinstall.js +139 -0
  202. package/templates/browser-test-flow.json +56 -0
  203. package/templates/bug-report.md +43 -0
  204. package/templates/component-detail.md +42 -0
  205. package/templates/component.stories.tsx +49 -0
  206. package/templates/context/constraints.md +83 -0
  207. package/templates/context/conventions.md +177 -0
  208. package/templates/context/stack.md +60 -0
  209. package/templates/correction-report.md +90 -0
  210. package/templates/feature-proposal.md +35 -0
  211. package/templates/hybrid/_base.md +254 -0
  212. package/templates/hybrid/_patterns.md +45 -0
  213. package/templates/hybrid/create-component.md +127 -0
  214. package/templates/hybrid/create-file.md +56 -0
  215. package/templates/hybrid/create-hook.md +145 -0
  216. package/templates/hybrid/create-service.md +70 -0
  217. package/templates/hybrid/fix-bug.md +33 -0
  218. package/templates/hybrid/modify-file.md +55 -0
  219. package/templates/story.md +68 -0
  220. package/templates/task.json +56 -0
  221. package/templates/trace.md +69 -0
@@ -0,0 +1,2242 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Wogi Flow - Shared Utilities
5
+ *
6
+ * Common functions used across all flow scripts.
7
+ * Eliminates Python dependency and provides consistent path handling.
8
+ *
9
+ * NOTE: For new code, prefer importing from dedicated modules:
10
+ * - flow-output.js: colors, color, print, success, warn, error, info
11
+ * - flow-file-ops.js: readJson, writeJson, fileExists, dirExists, ensureDir
12
+ * - flow-constants.js: TIMEOUTS, LIMITS, THRESHOLDS, BACKOFF
13
+ * - flow-http-client.js: HttpClient, fetchJson, postJson
14
+ *
15
+ * This file re-exports all functions for backwards compatibility.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const crypto = require('crypto');
21
+ const { execSync } = require('child_process');
22
+
23
+ // Late-loaded to avoid circular dependency
24
+ let configSubstitution = null;
25
+ function getConfigSubstitution() {
26
+ if (!configSubstitution) {
27
+ configSubstitution = require('../.workflow/lib/config-substitution');
28
+ }
29
+ return configSubstitution;
30
+ }
31
+
32
+ // ============================================================
33
+ // Constants - Named values for magic numbers
34
+ // ============================================================
35
+
36
+ /** Default timeout for shell commands (2 minutes) */
37
+ const DEFAULT_COMMAND_TIMEOUT_MS = 120000;
38
+
39
+ /** Quick command timeout (30 seconds) */
40
+ const QUICK_COMMAND_TIMEOUT_MS = 30000;
41
+
42
+ /** Default lock stale threshold (60 seconds) */
43
+ const LOCK_STALE_THRESHOLD_MS = 60000;
44
+
45
+ /** Cleanup lock stale threshold (30 seconds) */
46
+ const CLEANUP_LOCK_STALE_MS = 30000;
47
+
48
+ /** Default retry delay for lock acquisition (100ms) */
49
+ const LOCK_RETRY_DELAY_MS = 100;
50
+
51
+ /** Default max retries for lock acquisition */
52
+ const LOCK_MAX_RETRIES = 5;
53
+
54
+ /** Maximum history entries to keep in durable sessions */
55
+ const MAX_SESSION_HISTORY = 50;
56
+
57
+ /** Default max iterations for workflow loops */
58
+ const MAX_WORKFLOW_ITERATIONS = 100;
59
+
60
+ // ============================================================
61
+ // Project Root Detection
62
+ // ============================================================
63
+
64
+ /**
65
+ * Find the project root directory using multiple strategies:
66
+ * 1. Git root (most reliable in monorepos and submodules)
67
+ * 2. Walk up looking for .workflow directory
68
+ * 3. Fall back to process.cwd()
69
+ *
70
+ * @returns {string} Absolute path to project root
71
+ */
72
+ function getProjectRoot() {
73
+ // Strategy 1: Try git root (works in submodules, worktrees, and nested repos)
74
+ try {
75
+ const gitRoot = execSync('git rev-parse --show-toplevel', {
76
+ encoding: 'utf-8',
77
+ stdio: ['pipe', 'pipe', 'pipe'] // Suppress stderr
78
+ }).trim();
79
+
80
+ if (gitRoot && fs.existsSync(gitRoot)) {
81
+ // Verify this git root has .workflow (could be parent repo in monorepo)
82
+ if (fs.existsSync(path.join(gitRoot, '.workflow'))) {
83
+ return gitRoot;
84
+ }
85
+ }
86
+ } catch {
87
+ // Not in a git repo or git not available
88
+ }
89
+
90
+ // Strategy 2: Walk up from cwd looking for .workflow
91
+ let current = process.cwd();
92
+ const root = path.parse(current).root;
93
+
94
+ while (current !== root) {
95
+ const workflowPath = path.join(current, '.workflow');
96
+ if (fs.existsSync(workflowPath) && fs.statSync(workflowPath).isDirectory()) {
97
+ return current;
98
+ }
99
+ current = path.dirname(current);
100
+ }
101
+
102
+ // Strategy 3: Fall back to cwd (for new projects without .workflow yet)
103
+ return process.cwd();
104
+ }
105
+
106
+ // ============================================================
107
+ // Paths
108
+ // ============================================================
109
+
110
+ const PROJECT_ROOT = getProjectRoot();
111
+ const WORKFLOW_DIR = path.join(PROJECT_ROOT, '.workflow');
112
+ const STATE_DIR = path.join(WORKFLOW_DIR, 'state');
113
+
114
+ const CLAUDE_DIR = path.join(PROJECT_ROOT, '.claude');
115
+
116
+ const PATHS = {
117
+ root: PROJECT_ROOT,
118
+ workflow: WORKFLOW_DIR,
119
+ state: STATE_DIR,
120
+ claude: CLAUDE_DIR,
121
+ config: path.join(WORKFLOW_DIR, 'config.json'),
122
+ ready: path.join(STATE_DIR, 'ready.json'),
123
+ requestLog: path.join(STATE_DIR, 'request-log.md'),
124
+ appMap: path.join(STATE_DIR, 'app-map.md'),
125
+ decisions: path.join(STATE_DIR, 'decisions.md'),
126
+ progress: path.join(STATE_DIR, 'progress.md'),
127
+ feedbackPatterns: path.join(STATE_DIR, 'feedback-patterns.md'),
128
+ components: path.join(STATE_DIR, 'components'),
129
+ changes: path.join(WORKFLOW_DIR, 'changes'),
130
+ bugs: path.join(WORKFLOW_DIR, 'bugs'),
131
+ archive: path.join(WORKFLOW_DIR, 'archive'),
132
+ specs: path.join(WORKFLOW_DIR, 'specs'),
133
+ // Additional workflow directories
134
+ runs: path.join(WORKFLOW_DIR, 'runs'),
135
+ checkpoints: path.join(WORKFLOW_DIR, 'checkpoints'),
136
+ corrections: path.join(WORKFLOW_DIR, 'corrections'),
137
+ traces: path.join(WORKFLOW_DIR, 'traces'),
138
+ // Advanced workflow features
139
+ commandMetrics: path.join(STATE_DIR, 'command-metrics.json'),
140
+ modelStats: path.join(STATE_DIR, 'model-stats.json'),
141
+ approaches: path.join(STATE_DIR, 'approaches'),
142
+ modelAdapters: path.join(WORKFLOW_DIR, 'model-adapters'),
143
+ codebaseInsights: path.join(STATE_DIR, 'codebase-insights.md'),
144
+ // Claude Code integration (v2.1.0)
145
+ skills: path.join(CLAUDE_DIR, 'skills'),
146
+ rules: path.join(CLAUDE_DIR, 'rules'),
147
+ commands: path.join(CLAUDE_DIR, 'commands'),
148
+ // Knowledge files (Phase 0.4 - synced documentation)
149
+ stackMd: path.join(STATE_DIR, 'stack.md'),
150
+ architectureMd: path.join(STATE_DIR, 'architecture.md'),
151
+ testingMd: path.join(STATE_DIR, 'testing.md'),
152
+ knowledgeSync: path.join(STATE_DIR, 'knowledge-sync.json'),
153
+ };
154
+
155
+ // ============================================================
156
+ // Colors (ANSI escape codes)
157
+ // ============================================================
158
+
159
+ const colors = {
160
+ reset: '\x1b[0m',
161
+ bold: '\x1b[1m',
162
+ dim: '\x1b[2m',
163
+
164
+ red: '\x1b[31m',
165
+ green: '\x1b[32m',
166
+ yellow: '\x1b[33m',
167
+ blue: '\x1b[34m',
168
+ magenta: '\x1b[35m',
169
+ cyan: '\x1b[36m',
170
+ white: '\x1b[37m',
171
+
172
+ bgRed: '\x1b[41m',
173
+ bgGreen: '\x1b[42m',
174
+ bgYellow: '\x1b[43m',
175
+ bgBlue: '\x1b[44m',
176
+ };
177
+
178
+ /**
179
+ * Colorize text for terminal output
180
+ */
181
+ function color(colorName, text) {
182
+ if (process.env.DEBUG && !colors[colorName]) {
183
+ console.warn(`[DEBUG] Unknown color: "${colorName}"`);
184
+ }
185
+ return `${colors[colorName] || ''}${text}${colors.reset}`;
186
+ }
187
+
188
+ /**
189
+ * Print colored output
190
+ */
191
+ function print(colorName, text) {
192
+ console.log(color(colorName, text));
193
+ }
194
+
195
+ /**
196
+ * Print a styled header
197
+ */
198
+ function printHeader(title) {
199
+ console.log(color('cyan', '═'.repeat(50)));
200
+ console.log(color('cyan', ` ${title}`));
201
+ console.log(color('cyan', '═'.repeat(50)));
202
+ console.log('');
203
+ }
204
+
205
+ /**
206
+ * Print a section title
207
+ */
208
+ function printSection(title) {
209
+ console.log(color('cyan', title));
210
+ }
211
+
212
+ // ============================================================
213
+ // Standard Messaging Functions
214
+ // ============================================================
215
+ //
216
+ // STANDARD: All scripts should use these functions for consistent output:
217
+ // success(msg) - Green checkmark ✓ for successful operations
218
+ // warn(msg) - Yellow warning ⚠ for non-fatal issues
219
+ // error(msg) - Red X ✗ for errors (use before process.exit(1))
220
+ // info(msg) - Cyan info ℹ for informational messages
221
+ //
222
+ // Import with: const { success, warn, error, info } = require('./flow-utils');
223
+ //
224
+ // AVOID: Direct console.log with color() for status messages.
225
+ // ============================================================
226
+
227
+ /**
228
+ * Print success message
229
+ */
230
+ function success(message) {
231
+ console.log(`${color('green', '✓')} ${message}`);
232
+ }
233
+
234
+ /**
235
+ * Print warning message
236
+ */
237
+ function warn(message) {
238
+ console.log(`${color('yellow', '⚠')} ${message}`);
239
+ }
240
+
241
+ /**
242
+ * Print error message
243
+ */
244
+ function error(message) {
245
+ console.log(`${color('red', '✗')} ${message}`);
246
+ }
247
+
248
+ /**
249
+ * Print info message
250
+ */
251
+ function info(message) {
252
+ console.log(`${color('cyan', 'ℹ')} ${message}`);
253
+ }
254
+
255
+ // ============================================================
256
+ // Task ID Generation (hash-based IDs)
257
+ // ============================================================
258
+
259
+ /**
260
+ * Generate a hash-based task ID
261
+ * Format: wf-XXXXXXXX (8-char hex hash)
262
+ *
263
+ * Uses SHA256 hash of title + timestamp for collision resistance.
264
+ * This prevents merge conflicts in multi-agent/multi-branch workflows.
265
+ *
266
+ * @param {string} title - Task title
267
+ * @returns {string} Task ID in format wf-XXXXXXXX
268
+ *
269
+ * @example
270
+ * generateTaskId('Fix login bug') // => 'wf-a1b2c3d4'
271
+ */
272
+ function generateTaskId(title) {
273
+ const input = `${title}${Date.now()}${Math.random()}`;
274
+ const hash = crypto.createHash('sha256').update(input).digest('hex').slice(0, 8);
275
+ return `wf-${hash}`;
276
+ }
277
+
278
+ /**
279
+ * Check if a string is a valid task ID (old or new format)
280
+ * @param {string} id - ID to validate
281
+ * @returns {{ valid: boolean, format: 'hash' | 'legacy' | null }}
282
+ */
283
+ function validateTaskId(id) {
284
+ if (!id || typeof id !== 'string') {
285
+ return { valid: false, format: null };
286
+ }
287
+
288
+ // New hash-based format: wf-XXXXXXXX
289
+ if (/^wf-[a-f0-9]{8}$/i.test(id)) {
290
+ return { valid: true, format: 'hash' };
291
+ }
292
+
293
+ // Legacy formats: TASK-XXX, BUG-XXX
294
+ if (/^(TASK|BUG)-\d{3,}$/i.test(id)) {
295
+ return { valid: true, format: 'legacy' };
296
+ }
297
+
298
+ return { valid: false, format: null };
299
+ }
300
+
301
+ /**
302
+ * Check if ID is in legacy format (for migration warnings)
303
+ * @param {string} id - ID to check
304
+ * @returns {boolean}
305
+ */
306
+ function isLegacyTaskId(id) {
307
+ return /^(TASK|BUG)-\d{3,}$/i.test(id);
308
+ }
309
+
310
+ // ============================================================
311
+ // JSON Output Helpers (for --json flag support)
312
+ // ============================================================
313
+
314
+ /**
315
+ * Output data as JSON and exit
316
+ * Use this in scripts that support --json flag
317
+ *
318
+ * @param {Object} data - Data to output
319
+ * @param {Object} [options] - Options
320
+ * @param {boolean} [options.exitOnOutput=true] - Exit after output
321
+ * @param {number} [options.exitCode=0] - Exit code
322
+ *
323
+ * @example
324
+ * if (flags.json) {
325
+ * outputJson({ success: true, tasks: [...] });
326
+ * }
327
+ */
328
+ function outputJson(data, options = {}) {
329
+ const { exitOnOutput = true, exitCode = 0 } = options;
330
+
331
+ const output = {
332
+ success: data.success !== false,
333
+ timestamp: new Date().toISOString(),
334
+ ...data
335
+ };
336
+
337
+ console.log(JSON.stringify(output, null, 2));
338
+
339
+ if (exitOnOutput) {
340
+ process.exit(exitCode);
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Parse common CLI flags from arguments
346
+ * Standardizes flag handling across all flow commands
347
+ *
348
+ * @param {string[]} args - Command line arguments (process.argv.slice(2))
349
+ * @returns {{ flags: Object, positional: string[] }}
350
+ *
351
+ * @example
352
+ * const { flags, positional } = parseFlags(process.argv.slice(2));
353
+ * if (flags.json) outputJson(result);
354
+ * if (flags.help) showHelp();
355
+ */
356
+ function parseFlags(args) {
357
+ const flags = {
358
+ json: false,
359
+ quiet: false,
360
+ verbose: false,
361
+ help: false,
362
+ dryRun: false,
363
+ deep: false
364
+ };
365
+
366
+ const positional = [];
367
+ const namedFlags = {};
368
+
369
+ // Known flags that take values (--flag value style)
370
+ const valuedFlags = ['priority', 'from', 'severity', 'limit', 'format', 'output', 'strategy', 'type', 'file', 'analysis', 'model', 'domain', 'task-type'];
371
+
372
+ for (let i = 0; i < args.length; i++) {
373
+ const arg = args[i];
374
+
375
+ if (arg === '--json') {
376
+ flags.json = true;
377
+ } else if (arg === '--quiet' || arg === '-q') {
378
+ flags.quiet = true;
379
+ } else if (arg === '--verbose' || arg === '-v') {
380
+ flags.verbose = true;
381
+ } else if (arg === '--help' || arg === '-h') {
382
+ flags.help = true;
383
+ } else if (arg === '--dry-run') {
384
+ flags.dryRun = true;
385
+ } else if (arg === '--deep') {
386
+ flags.deep = true;
387
+ } else if (arg.startsWith('--')) {
388
+ // Handle --key=value style flags
389
+ const match = arg.match(/^--([^=]+)(?:=(.*))?$/);
390
+ if (match) {
391
+ const [, key, value] = match;
392
+ if (value !== undefined) {
393
+ // Has explicit value: --key=value
394
+ namedFlags[key] = value;
395
+ } else if (valuedFlags.includes(key) && i + 1 < args.length && !args[i + 1].startsWith('-')) {
396
+ // Known valued flag: --key value (consume next arg)
397
+ namedFlags[key] = args[++i];
398
+ } else if (valuedFlags.includes(key)) {
399
+ // Valued flag without value - warn in debug mode, treat as boolean
400
+ if (process.env.DEBUG) {
401
+ console.warn(`[DEBUG] Flag --${key} expects a value but none provided`);
402
+ }
403
+ namedFlags[key] = true;
404
+ } else {
405
+ // Boolean flag: --flag
406
+ namedFlags[key] = true;
407
+ }
408
+ }
409
+ } else if (!arg.startsWith('-')) {
410
+ positional.push(arg);
411
+ }
412
+ }
413
+
414
+ return { flags: { ...flags, ...namedFlags }, positional };
415
+ }
416
+
417
+ // ============================================================
418
+ // File Operations
419
+ // ============================================================
420
+
421
+ /**
422
+ * Check if a file exists
423
+ */
424
+ function fileExists(filePath) {
425
+ try {
426
+ return fs.existsSync(filePath);
427
+ } catch {
428
+ return false;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Check if a directory exists
434
+ */
435
+ function dirExists(dirPath) {
436
+ try {
437
+ return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory();
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ /**
444
+ * Read JSON file safely
445
+ * @param {string} filePath - Path to JSON file
446
+ * @param {*} [defaultValue=undefined] - Default value if file doesn't exist or is invalid
447
+ * @returns {*} Parsed JSON or defaultValue
448
+ * @throws {Error} If file cannot be read and no defaultValue provided
449
+ */
450
+ function readJson(filePath, defaultValue = undefined) {
451
+ try {
452
+ const content = fs.readFileSync(filePath, 'utf-8');
453
+ return JSON.parse(content);
454
+ } catch (err) {
455
+ // Check for undefined to allow falsy defaults like false, 0, ''
456
+ if (defaultValue !== undefined) {
457
+ return defaultValue;
458
+ }
459
+ throw new Error(`Failed to read JSON from ${filePath}: ${err.message}`);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Write JSON file with pretty formatting using atomic write pattern
465
+ * (writes to temp file, then renames for crash safety)
466
+ * @param {string} filePath - Path to JSON file
467
+ * @param {*} data - Data to serialize as JSON
468
+ * @returns {boolean} True on success
469
+ * @throws {Error} If file cannot be written
470
+ */
471
+ function writeJson(filePath, data) {
472
+ const tempPath = filePath + '.tmp.' + process.pid;
473
+ try {
474
+ const content = JSON.stringify(data, null, 2) + '\n';
475
+ fs.writeFileSync(tempPath, content);
476
+ fs.renameSync(tempPath, filePath); // Atomic rename
477
+ return true;
478
+ } catch (err) {
479
+ // Clean up temp file if it exists
480
+ try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
481
+ throw new Error(`Failed to write JSON to ${filePath}: ${err.message}`);
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Safely parse JSON with prototype pollution protection
487
+ * Use this for user-modifiable files (registry, stats, etc.)
488
+ * @param {string} filePath - Path to JSON file
489
+ * @param {*} [defaultValue=null] - Default value if parsing fails
490
+ * @returns {object|null} Parsed JSON or defaultValue on error
491
+ */
492
+ function safeJsonParse(filePath, defaultValue = null) {
493
+ try {
494
+ const content = fs.readFileSync(filePath, 'utf-8');
495
+
496
+ // Check for prototype pollution attempts in raw content
497
+ // Covers various quote styles and whitespace variants
498
+ if (/__proto__|constructor\s*["'`:]|prototype\s*["'`:]/i.test(content)) {
499
+ console.error(`[safeJsonParse] Suspicious content detected in ${filePath}`);
500
+ return defaultValue;
501
+ }
502
+
503
+ const parsed = JSON.parse(content);
504
+
505
+ // Validate it's an object (not array or primitive for config files)
506
+ if (typeof parsed !== 'object' || parsed === null) {
507
+ console.error(`[safeJsonParse] Invalid JSON structure in ${filePath} (expected object)`);
508
+ return defaultValue;
509
+ }
510
+
511
+ // Additional check: ensure no proto/constructor keys were added
512
+ const keys = Object.getOwnPropertyNames(parsed);
513
+ if (keys.includes('__proto__') || keys.includes('constructor') || keys.includes('prototype')) {
514
+ console.error(`[safeJsonParse] Prototype pollution attempt detected in ${filePath}`);
515
+ return defaultValue;
516
+ }
517
+
518
+ return parsed;
519
+ } catch (err) {
520
+ console.error(`[safeJsonParse] Failed to parse ${filePath}: ${err.message}`);
521
+ return defaultValue;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Read text file safely
527
+ * @param {string} filePath - Path to text file
528
+ * @param {*} [defaultValue=undefined] - Default value if file doesn't exist
529
+ * @returns {string|*} File contents or defaultValue
530
+ * @throws {Error} If file cannot be read and no defaultValue provided
531
+ */
532
+ function readFile(filePath, defaultValue = undefined) {
533
+ try {
534
+ return fs.readFileSync(filePath, 'utf-8');
535
+ } catch (err) {
536
+ // Check for undefined to allow falsy defaults like false, 0, ''
537
+ if (defaultValue !== undefined) {
538
+ return defaultValue;
539
+ }
540
+ throw new Error(`Failed to read file ${filePath}: ${err.message}`);
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Write text file using atomic write pattern
546
+ * (writes to temp file, then renames for crash safety)
547
+ */
548
+ function writeFile(filePath, content) {
549
+ const tempPath = filePath + '.tmp.' + process.pid;
550
+ try {
551
+ fs.writeFileSync(tempPath, content);
552
+ fs.renameSync(tempPath, filePath); // Atomic rename
553
+ return true;
554
+ } catch (err) {
555
+ // Clean up temp file if it exists
556
+ try { fs.unlinkSync(tempPath); } catch { /* ignore */ }
557
+ throw new Error(`Failed to write file ${filePath}: ${err.message}`);
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Check if a path is within the project directory (prevents path traversal)
563
+ * @param {string} targetPath - Path to validate
564
+ * @param {string} [baseDir=PROJECT_ROOT] - Base directory to check against
565
+ * @returns {boolean} True if path is within base directory
566
+ */
567
+ function isPathWithinProject(targetPath, baseDir = PROJECT_ROOT) {
568
+ const resolved = path.resolve(targetPath);
569
+ const resolvedBase = path.resolve(baseDir);
570
+ return resolved === resolvedBase || resolved.startsWith(resolvedBase + path.sep);
571
+ }
572
+
573
+ /**
574
+ * Validate JSON file syntax
575
+ */
576
+ function validateJson(filePath) {
577
+ try {
578
+ const content = fs.readFileSync(filePath, 'utf-8');
579
+ JSON.parse(content);
580
+ return { valid: true };
581
+ } catch (err) {
582
+ return { valid: false, error: err.message };
583
+ }
584
+ }
585
+
586
+ // ============================================================
587
+ // Config Operations
588
+ // ============================================================
589
+
590
+ // Config cache for performance (avoids repeated file reads)
591
+ let _configCache = null;
592
+ let _configMtime = null;
593
+
594
+ // Known config keys for validation (prevents typos causing silent failures)
595
+ const KNOWN_CONFIG_KEYS = [
596
+ 'hybrid',
597
+ 'parallel',
598
+ 'worktree',
599
+ 'qualityGates',
600
+ 'testing',
601
+ 'componentRules',
602
+ 'mandatorySteps',
603
+ 'phases',
604
+ 'corrections',
605
+ 'skills',
606
+ 'autoContext',
607
+ 'metrics',
608
+ 'figmaAnalyzer',
609
+ 'learning',
610
+ 'hooks',
611
+ 'project',
612
+ 'projectType',
613
+ // v1.7.0 context memory management
614
+ 'contextMonitor',
615
+ 'requestLog',
616
+ 'sessionState',
617
+ // v1.9.0 features
618
+ 'priorities',
619
+ 'morningBriefing'
620
+ ];
621
+
622
+ // Known nested keys for common config sections
623
+ const KNOWN_NESTED_KEYS = {
624
+ hybrid: ['enabled', 'provider', 'providerEndpoint', 'model', 'settings', 'maxContextTokens', 'apiKey'],
625
+ parallel: ['enabled', 'maxConcurrent', 'autoApprove', 'requireWorktree', 'showProgress'],
626
+ worktree: ['enabled', 'autoCleanupHours', 'keepOnFailure', 'squashOnMerge'],
627
+ testing: ['runAfterTask', 'runBeforeCommit', 'command'],
628
+ learning: ['autoPromote', 'enabled', 'threshold', 'mode'],
629
+ qualityGates: ['feature', 'bugfix'],
630
+ autoContext: ['enabled', 'maxFiles', 'searchDepth'],
631
+ // v1.7.0 context memory management
632
+ contextMonitor: ['enabled', 'warnAt', 'criticalAt', 'contextWindow', 'checkOnSessionStart', 'checkAfterTask'],
633
+ requestLog: ['enabled', 'autoArchive', 'maxRecentEntries', 'keepRecent', 'createSummary'],
634
+ sessionState: ['enabled', 'autoRestore', 'maxGapHours', 'trackFiles', 'trackDecisions', 'maxRecentFiles', 'maxRecentDecisions'],
635
+ // v1.9.0 features
636
+ priorities: ['defaultPriority', 'autoBoostDays', 'autoBoostAmount'],
637
+ morningBriefing: ['enabled', 'showLastSession', 'showChanges', 'showRecommendedTasks', 'generatePrompt']
638
+ };
639
+
640
+ // Track if we've already warned about config issues this session
641
+ let _configValidationDone = false;
642
+
643
+ /**
644
+ * Validate config object for unknown keys
645
+ * Warns about typos that could cause silent failures
646
+ */
647
+ function validateConfig(config, warnOnUnknown = true) {
648
+ if (!warnOnUnknown || !config || typeof config !== 'object') return;
649
+
650
+ const warnings = [];
651
+
652
+ // Check top-level keys
653
+ for (const key of Object.keys(config)) {
654
+ if (!KNOWN_CONFIG_KEYS.includes(key)) {
655
+ warnings.push(`Unknown config key: "${key}"`);
656
+ }
657
+ }
658
+
659
+ // Check known nested sections
660
+ for (const [section, knownKeys] of Object.entries(KNOWN_NESTED_KEYS)) {
661
+ const sectionConfig = config[section];
662
+ if (sectionConfig && typeof sectionConfig === 'object') {
663
+ for (const key of Object.keys(sectionConfig)) {
664
+ if (!knownKeys.includes(key)) {
665
+ warnings.push(`Unknown key in ${section}: "${key}"`);
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ // Only warn once per session (avoid spam)
672
+ if (warnings.length > 0 && !_configValidationDone) {
673
+ _configValidationDone = true;
674
+ for (const warning of warnings) {
675
+ console.warn(`⚠️ ${warning}`);
676
+ }
677
+ console.warn(' Check for typos in .workflow/config.json');
678
+ }
679
+ }
680
+
681
+ /**
682
+ * Read workflow config (cached, invalidates on file change)
683
+ * Applies variable substitution ({env:VAR}, {file:path}) to config values
684
+ */
685
+ function getConfig() {
686
+ const configPath = PATHS.config;
687
+ if (!fs.existsSync(configPath)) return {};
688
+
689
+ try {
690
+ const stat = fs.statSync(configPath);
691
+ if (_configCache && _configMtime === stat.mtimeMs) {
692
+ return _configCache;
693
+ }
694
+
695
+ const rawConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
696
+ _configMtime = stat.mtimeMs;
697
+
698
+ // Validate on first load (DEBUG mode or explicit request)
699
+ if (process.env.DEBUG || process.env.VALIDATE_CONFIG) {
700
+ validateConfig(rawConfig);
701
+ }
702
+
703
+ // Apply variable substitution ({env:VAR}, {file:path})
704
+ try {
705
+ const { substituteConfig } = getConfigSubstitution();
706
+ const result = substituteConfig(rawConfig, {
707
+ logWarnings: true,
708
+ printWarnings: process.env.DEBUG || process.env.VERBOSE_CONFIG
709
+ });
710
+ _configCache = result.value;
711
+
712
+ // Log substitution warnings once per session (if DEBUG)
713
+ if (process.env.DEBUG && result.warnings.length > 0) {
714
+ console.warn(`[config] ${result.warnings.length} unresolved substitution(s)`);
715
+ }
716
+ } catch (substErr) {
717
+ // Fallback to raw config if substitution fails
718
+ console.warn(`Warning: Config substitution failed: ${substErr.message}`);
719
+ _configCache = rawConfig;
720
+ }
721
+
722
+ return _configCache;
723
+ } catch (err) {
724
+ // Log warning instead of silently returning empty config
725
+ console.warn(`Warning: Could not parse config.json: ${err.message}`);
726
+ return {};
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Read raw workflow config WITHOUT substitution (for editing/writing)
732
+ * Use this when you need to read/modify config without resolving variables
733
+ */
734
+ function getRawConfig() {
735
+ const configPath = PATHS.config;
736
+ if (!fs.existsSync(configPath)) return {};
737
+
738
+ try {
739
+ return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
740
+ } catch (err) {
741
+ console.warn(`Warning: Could not parse config.json: ${err.message}`);
742
+ return {};
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Invalidate config cache (call after writing config)
748
+ */
749
+ function invalidateConfigCache() {
750
+ _configCache = null;
751
+ _configMtime = null;
752
+ }
753
+
754
+ /**
755
+ * Get a config value by path (e.g., 'testing.runBeforeCommit')
756
+ */
757
+ function getConfigValue(configPath, defaultValue = null) {
758
+ const config = getConfig();
759
+ const parts = configPath.split('.');
760
+ let value = config;
761
+
762
+ for (const part of parts) {
763
+ if (value && typeof value === 'object' && part in value) {
764
+ value = value[part];
765
+ } else {
766
+ return defaultValue;
767
+ }
768
+ }
769
+
770
+ return value;
771
+ }
772
+
773
+ /**
774
+ * Update config value (uses locking to prevent race conditions)
775
+ * SECURITY: Always acquires lock before writing to prevent data corruption
776
+ * @param {string} configPath - Dot-notation path (e.g., 'parallel.enabled')
777
+ * @param {*} newValue - New value to set
778
+ * @returns {Promise<void>}
779
+ * @throws {Error} If lock cannot be acquired after retries
780
+ */
781
+ async function setConfigValue(configPath, newValue) {
782
+ // Use file lock to prevent concurrent writes
783
+ const lockPath = PATHS.config;
784
+ let release;
785
+
786
+ try {
787
+ // More retries with exponential backoff for better reliability
788
+ release = await acquireLock(lockPath, { retries: 5, retryDelay: 100, exponentialBackoff: true });
789
+ } catch (lockError) {
790
+ // SECURITY: Don't fall back to non-locked write - throw instead
791
+ throw new Error(`Could not acquire config lock after retries: ${lockError.message}. Config not updated.`);
792
+ }
793
+
794
+ try {
795
+ // Re-read config after acquiring lock (may have changed)
796
+ invalidateConfigCache();
797
+ const config = getConfig();
798
+ const parts = configPath.split('.');
799
+ let obj = config;
800
+
801
+ for (let i = 0; i < parts.length - 1; i++) {
802
+ const part = parts[i];
803
+ if (!(part in obj)) {
804
+ obj[part] = {};
805
+ }
806
+ obj = obj[part];
807
+ }
808
+
809
+ obj[parts[parts.length - 1]] = newValue;
810
+ writeJson(PATHS.config, config);
811
+ invalidateConfigCache();
812
+ } finally {
813
+ if (release) release();
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Update config value (synchronous version - no locking)
819
+ * Use setConfigValue for concurrent-safe writes
820
+ */
821
+ function setConfigValueSync(configPath, newValue) {
822
+ const config = getConfig();
823
+ const parts = configPath.split('.');
824
+ let obj = config;
825
+
826
+ for (let i = 0; i < parts.length - 1; i++) {
827
+ const part = parts[i];
828
+ if (!(part in obj)) {
829
+ obj[part] = {};
830
+ }
831
+ obj = obj[part];
832
+ }
833
+
834
+ obj[parts[parts.length - 1]] = newValue;
835
+ writeJson(PATHS.config, config);
836
+ invalidateConfigCache();
837
+ }
838
+
839
+ /**
840
+ * Resolve config value that may contain environment variable or file references
841
+ * Supports: {env:VAR_NAME}, {file:path}, {file:~/path}
842
+ * @param {string|null} value - Value to resolve
843
+ * @returns {string|null} Resolved value or null if unresolvable
844
+ */
845
+ function resolveConfigValue(value) {
846
+ if (!value || typeof value !== 'string') return value;
847
+
848
+ // {env:VAR_NAME} - environment variable
849
+ if (value.startsWith('{env:') && value.endsWith('}')) {
850
+ const varName = value.slice(5, -1);
851
+ // Validate env var name format
852
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) {
853
+ warn(`Invalid environment variable name: ${varName}`);
854
+ return null;
855
+ }
856
+ return process.env[varName] || null;
857
+ }
858
+
859
+ // {file:path} - file contents
860
+ if (value.startsWith('{file:') && value.endsWith('}')) {
861
+ let filePath = value.slice(6, -1);
862
+ // Expand tilde to home directory
863
+ if (filePath.startsWith('~')) {
864
+ filePath = filePath.replace(/^~/, process.env.HOME || '');
865
+ }
866
+ // Security: validate path doesn't escape expected locations
867
+ const resolvedPath = path.resolve(filePath);
868
+ try {
869
+ return fs.readFileSync(resolvedPath, 'utf-8').trim();
870
+ } catch (err) {
871
+ return null;
872
+ }
873
+ }
874
+
875
+ return value;
876
+ }
877
+
878
+ // ============================================================
879
+ // Ready.json Operations
880
+ // ============================================================
881
+
882
+ /**
883
+ * Validate ready.json structure
884
+ * @param {Object} data - Data to validate
885
+ * @returns {Object} { valid: boolean, errors: string[] }
886
+ */
887
+ function validateReadyJson(data) {
888
+ const errors = [];
889
+
890
+ // Check required top-level arrays
891
+ const requiredArrays = ['ready', 'inProgress', 'blocked', 'recentlyCompleted'];
892
+ for (const key of requiredArrays) {
893
+ if (!Array.isArray(data[key])) {
894
+ errors.push(`Missing or invalid "${key}" array`);
895
+ }
896
+ }
897
+
898
+ if (errors.length > 0) {
899
+ return { valid: false, errors };
900
+ }
901
+
902
+ // Validate tasks in each array
903
+ const allArrays = [...requiredArrays];
904
+ for (const arrayName of allArrays) {
905
+ const tasks = data[arrayName] || [];
906
+ for (let i = 0; i < tasks.length; i++) {
907
+ const task = tasks[i];
908
+ const prefix = `${arrayName}[${i}]`;
909
+
910
+ // Required fields
911
+ if (!task.id || typeof task.id !== 'string') {
912
+ errors.push(`${prefix}: missing or invalid "id"`);
913
+ }
914
+
915
+ // Optional but validated fields
916
+ if (task.title !== undefined && typeof task.title !== 'string') {
917
+ errors.push(`${prefix}: "title" must be a string`);
918
+ }
919
+ if (task.status !== undefined && typeof task.status !== 'string') {
920
+ errors.push(`${prefix}: "status" must be a string`);
921
+ }
922
+ if (task.priority !== undefined && !/^P[0-4]$/.test(task.priority)) {
923
+ errors.push(`${prefix}: "priority" must be P0-P4`);
924
+ }
925
+ if (task.dependencies !== undefined && !Array.isArray(task.dependencies)) {
926
+ errors.push(`${prefix}: "dependencies" must be an array`);
927
+ }
928
+ }
929
+ }
930
+
931
+ return { valid: errors.length === 0, errors };
932
+ }
933
+
934
+ /**
935
+ * Read ready.json task queue with optional validation
936
+ * @param {boolean} [validate=false] - Whether to validate structure
937
+ * @returns {Object} Task queue data with ready, inProgress, blocked, recentlyCompleted arrays
938
+ * @throws {Error} If validate is true and structure is invalid
939
+ */
940
+ function getReadyData(validate = false) {
941
+ const data = readJson(PATHS.ready, {
942
+ ready: [],
943
+ inProgress: [],
944
+ blocked: [],
945
+ recentlyCompleted: []
946
+ });
947
+
948
+ if (validate) {
949
+ const validation = validateReadyJson(data);
950
+ if (!validation.valid) {
951
+ throw new Error(`Invalid ready.json: ${validation.errors.join(', ')}`);
952
+ }
953
+ }
954
+
955
+ return data;
956
+ }
957
+
958
+ /**
959
+ * Write ready.json task queue
960
+ * Note: Does not mutate the input data object
961
+ *
962
+ * WARNING: For concurrent access, use saveReadyDataAsync which uses file locking.
963
+ */
964
+ function saveReadyData(data) {
965
+ const toSave = { ...data, lastUpdated: new Date().toISOString() };
966
+ return writeJson(PATHS.ready, toSave);
967
+ }
968
+
969
+ /**
970
+ * Write ready.json with file locking (async version)
971
+ * Use this when multiple processes might be writing to ready.json
972
+ *
973
+ * SECURITY: Prevents race conditions that could corrupt ready.json
974
+ */
975
+ async function saveReadyDataAsync(data) {
976
+ return withLock(PATHS.ready, () => {
977
+ const toSave = { ...data, lastUpdated: new Date().toISOString() };
978
+ return writeJson(PATHS.ready, toSave);
979
+ });
980
+ }
981
+
982
+ /**
983
+ * Find a task in ready.json by ID
984
+ * Returns { task, list, index } or null
985
+ */
986
+ function findTask(taskId) {
987
+ const data = getReadyData();
988
+ const lists = ['ready', 'inProgress', 'blocked', 'recentlyCompleted'];
989
+
990
+ for (const listName of lists) {
991
+ const list = data[listName] || [];
992
+ for (let i = 0; i < list.length; i++) {
993
+ const task = list[i];
994
+ const id = typeof task === 'string' ? task : task.id;
995
+ if (id === taskId) {
996
+ return { task, list: listName, index: i, data };
997
+ }
998
+ }
999
+ }
1000
+
1001
+ return null;
1002
+ }
1003
+
1004
+ /**
1005
+ * Move a task from one list to another
1006
+ *
1007
+ * WARNING: For concurrent access, use moveTaskAsync which uses file locking.
1008
+ */
1009
+ function moveTask(taskId, fromList, toList) {
1010
+ const data = getReadyData();
1011
+ const from = data[fromList] || [];
1012
+ const to = data[toList] || [];
1013
+
1014
+ let taskIndex = -1;
1015
+ let task = null;
1016
+
1017
+ for (let i = 0; i < from.length; i++) {
1018
+ const t = from[i];
1019
+ const id = typeof t === 'string' ? t : t.id;
1020
+ if (id === taskId) {
1021
+ taskIndex = i;
1022
+ task = t;
1023
+ break;
1024
+ }
1025
+ }
1026
+
1027
+ if (taskIndex === -1) {
1028
+ return { success: false, error: `Task ${taskId} not found in ${fromList}` };
1029
+ }
1030
+
1031
+ from.splice(taskIndex, 1);
1032
+
1033
+ if (toList === 'recentlyCompleted') {
1034
+ to.unshift(task);
1035
+ data[toList] = to.slice(0, 10); // Keep last 10
1036
+ } else {
1037
+ to.push(task);
1038
+ data[toList] = to;
1039
+ }
1040
+
1041
+ data[fromList] = from;
1042
+ saveReadyData(data);
1043
+
1044
+ return { success: true, task };
1045
+ }
1046
+
1047
+ /**
1048
+ * Move a task with file locking (async version)
1049
+ * Atomically reads, modifies, and writes ready.json
1050
+ *
1051
+ * SECURITY: Prevents race conditions when multiple processes move tasks
1052
+ */
1053
+ async function moveTaskAsync(taskId, fromList, toList) {
1054
+ return withLock(PATHS.ready, () => {
1055
+ const data = getReadyData();
1056
+ const from = data[fromList] || [];
1057
+ const to = data[toList] || [];
1058
+
1059
+ let taskIndex = -1;
1060
+ let task = null;
1061
+
1062
+ for (let i = 0; i < from.length; i++) {
1063
+ const t = from[i];
1064
+ const id = typeof t === 'string' ? t : t.id;
1065
+ if (id === taskId) {
1066
+ taskIndex = i;
1067
+ task = t;
1068
+ break;
1069
+ }
1070
+ }
1071
+
1072
+ if (taskIndex === -1) {
1073
+ return { success: false, error: `Task ${taskId} not found in ${fromList}` };
1074
+ }
1075
+
1076
+ from.splice(taskIndex, 1);
1077
+
1078
+ if (toList === 'recentlyCompleted') {
1079
+ to.unshift(task);
1080
+ data[toList] = to.slice(0, 10); // Keep last 10
1081
+ } else {
1082
+ to.push(task);
1083
+ data[toList] = to;
1084
+ }
1085
+
1086
+ data[fromList] = from;
1087
+ const toSave = { ...data, lastUpdated: new Date().toISOString() };
1088
+ writeJson(PATHS.ready, toSave);
1089
+
1090
+ return { success: true, task };
1091
+ });
1092
+ }
1093
+
1094
+ /**
1095
+ * Get task counts
1096
+ */
1097
+ function getTaskCounts() {
1098
+ const data = getReadyData();
1099
+ return {
1100
+ ready: (data.ready || []).length,
1101
+ inProgress: (data.inProgress || []).length,
1102
+ blocked: (data.blocked || []).length,
1103
+ recentlyCompleted: (data.recentlyCompleted || []).length
1104
+ };
1105
+ }
1106
+
1107
+ // ============================================================
1108
+ // Request Log Operations
1109
+ // ============================================================
1110
+
1111
+ /**
1112
+ * Count entries in request-log.md
1113
+ */
1114
+ function countRequestLogEntries() {
1115
+ try {
1116
+ const content = readFile(PATHS.requestLog, '');
1117
+ const matches = content.match(/^### R-/gm);
1118
+ return matches ? matches.length : 0;
1119
+ } catch {
1120
+ return 0;
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Get the last request log entry
1126
+ */
1127
+ function getLastRequestLogEntry() {
1128
+ try {
1129
+ const content = readFile(PATHS.requestLog, '');
1130
+ const matches = content.match(/^### R-.*$/gm);
1131
+ return matches ? matches[matches.length - 1] : null;
1132
+ } catch {
1133
+ return null;
1134
+ }
1135
+ }
1136
+
1137
+ /**
1138
+ * Get the highest request ID number from request-log.md
1139
+ * More robust than counting - handles gaps and deleted entries
1140
+ */
1141
+ function getHighestRequestId() {
1142
+ try {
1143
+ const content = readFile(PATHS.requestLog, '');
1144
+ // Match all R-XXX patterns (3+ digits)
1145
+ const matches = content.match(/### R-(\d{3,})/g);
1146
+ if (!matches || matches.length === 0) return 0;
1147
+
1148
+ // Extract numbers and find the max
1149
+ const numbers = matches.map(m => {
1150
+ const num = m.match(/R-(\d+)/);
1151
+ return num ? parseInt(num[1], 10) : 0;
1152
+ });
1153
+ return Math.max(...numbers);
1154
+ } catch {
1155
+ return 0;
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Get next request ID
1161
+ * Uses highest existing ID + 1 to avoid duplicates even with gaps
1162
+ */
1163
+ function getNextRequestId() {
1164
+ const highestId = getHighestRequestId();
1165
+ return `R-${String(highestId + 1).padStart(3, '0')}`;
1166
+ }
1167
+
1168
+ /**
1169
+ * Add an entry to request-log.md
1170
+ * @param {Object} entry - Entry details
1171
+ * @param {string} entry.type - new | fix | change | refactor
1172
+ * @param {string[]} entry.tags - Array of tags (e.g., ['#figma', '#component:Button'])
1173
+ * @param {string} entry.request - What was requested
1174
+ * @param {string} entry.result - What was done
1175
+ * @param {string[]} [entry.files] - Files changed
1176
+ */
1177
+ function addRequestLogEntry(entry) {
1178
+ const { type, tags, request, result, files = [] } = entry;
1179
+ const id = getNextRequestId();
1180
+ const now = new Date();
1181
+ const timestamp = now.toISOString().replace('T', ' ').substring(0, 16);
1182
+
1183
+ const filesLine = files.length > 0 ? `\n**Files**: ${files.join(', ')}` : '';
1184
+ const tagsStr = tags.join(' ');
1185
+
1186
+ const logEntry = `
1187
+ ### ${id} | ${timestamp}
1188
+ **Type**: ${type}
1189
+ **Tags**: ${tagsStr}
1190
+ **Request**: "${request}"
1191
+ **Result**: ${result}${filesLine}
1192
+ `;
1193
+
1194
+ try {
1195
+ const content = readFile(PATHS.requestLog, '');
1196
+ writeFile(PATHS.requestLog, content + logEntry);
1197
+ return id;
1198
+ } catch (err) {
1199
+ error(`Failed to add request log entry: ${err.message}`);
1200
+ return null;
1201
+ }
1202
+ }
1203
+
1204
+ // ============================================================
1205
+ // App Map Operations
1206
+ // ============================================================
1207
+
1208
+ /**
1209
+ * Count components in app-map.md
1210
+ * Counts actual data rows (excludes headers and separator rows)
1211
+ */
1212
+ function countAppMapComponents() {
1213
+ try {
1214
+ const content = readFile(PATHS.appMap, '');
1215
+ // Match data rows: start with | followed by non-dash content (excludes |---|---|)
1216
+ const dataRows = content.match(/^\|[^-|][^|]*\|/gm);
1217
+ // Each table has 1 header row per section, estimate ~2-3 sections
1218
+ const headerCount = (content.match(/^## /gm) || []).length * 1;
1219
+ const count = dataRows ? Math.max(0, dataRows.length - headerCount) : 0;
1220
+ return count;
1221
+ } catch {
1222
+ return 0;
1223
+ }
1224
+ }
1225
+
1226
+ /**
1227
+ * Add a component to app-map.md
1228
+ * @param {Object} component - Component details
1229
+ * @param {string} component.name - Component name
1230
+ * @param {string} component.type - Component type (component, screen, modal, etc.)
1231
+ * @param {string} component.path - Path to component file
1232
+ * @param {string[]} [component.variants] - Available variants
1233
+ * @param {string} [component.description] - Component description
1234
+ * @returns {boolean} - Success status
1235
+ */
1236
+ function addAppMapComponent(component) {
1237
+ const { name, type, path: filePath, variants = [], description = '' } = component;
1238
+
1239
+ try {
1240
+ let content = readFile(PATHS.appMap, '');
1241
+
1242
+ // Find the appropriate section based on type
1243
+ const sectionMap = {
1244
+ screen: '## Screens',
1245
+ modal: '## Modals',
1246
+ component: '## Components',
1247
+ layout: '## Layouts'
1248
+ };
1249
+
1250
+ const section = sectionMap[type] || '## Components';
1251
+ const variantsStr = variants.length > 0 ? variants.join(', ') : '-';
1252
+ const descStr = description || '-';
1253
+
1254
+ // Create new row
1255
+ const newRow = `| ${name} | ${filePath} | ${variantsStr} | ${descStr} |`;
1256
+
1257
+ // Find section and add row
1258
+ const sectionIndex = content.indexOf(section);
1259
+ if (sectionIndex === -1) {
1260
+ warn(`Section "${section}" not found in app-map.md`);
1261
+ return false;
1262
+ }
1263
+
1264
+ // Find the end of the table in this section (next section or end of file)
1265
+ const nextSectionMatch = content.substring(sectionIndex + section.length).match(/\n## /);
1266
+ const endIndex = nextSectionMatch
1267
+ ? sectionIndex + section.length + nextSectionMatch.index
1268
+ : content.length;
1269
+
1270
+ // Find last table row in section
1271
+ const sectionContent = content.substring(sectionIndex, endIndex);
1272
+ const lastPipeIndex = sectionContent.lastIndexOf('\n|');
1273
+
1274
+ if (lastPipeIndex !== -1) {
1275
+ // Find the end of the last row (next newline after the pipe)
1276
+ const afterPipe = sectionContent.substring(lastPipeIndex);
1277
+ const newlineOffset = afterPipe.indexOf('\n', 1);
1278
+ // If no newline found, insert at end of section content
1279
+ const insertOffset = newlineOffset !== -1 ? newlineOffset : afterPipe.length;
1280
+ const insertIndex = sectionIndex + lastPipeIndex + insertOffset;
1281
+ content = content.substring(0, insertIndex) + '\n' + newRow + content.substring(insertIndex);
1282
+ } else {
1283
+ // No table rows yet, add after header
1284
+ const headerEnd = sectionContent.indexOf('\n\n');
1285
+ if (headerEnd !== -1) {
1286
+ const insertIndex = sectionIndex + headerEnd;
1287
+ content = content.substring(0, insertIndex) + '\n' + newRow + content.substring(insertIndex);
1288
+ } else {
1289
+ // Malformed section - no header end found
1290
+ warn(`Could not find proper insertion point in section "${section}"`);
1291
+ return false;
1292
+ }
1293
+ }
1294
+
1295
+ writeFile(PATHS.appMap, content);
1296
+ return true;
1297
+ } catch (err) {
1298
+ error(`Failed to add component to app-map: ${err.message}`);
1299
+ return false;
1300
+ }
1301
+ }
1302
+
1303
+ // ============================================================
1304
+ // Git Operations
1305
+ // ============================================================
1306
+
1307
+ /**
1308
+ * Check if current directory is a git repo
1309
+ * Note: .git can be a directory (normal repo) or file (worktree)
1310
+ */
1311
+ function isGitRepo() {
1312
+ const gitPath = path.join(PROJECT_ROOT, '.git');
1313
+ return fs.existsSync(gitPath);
1314
+ }
1315
+
1316
+ /**
1317
+ * Get git status info (requires child_process)
1318
+ */
1319
+ function getGitStatus() {
1320
+ const { execSync } = require('child_process');
1321
+
1322
+ if (!isGitRepo()) {
1323
+ return { isRepo: false };
1324
+ }
1325
+
1326
+ try {
1327
+ const branch = execSync('git branch --show-current', {
1328
+ encoding: 'utf-8',
1329
+ stdio: ['pipe', 'pipe', 'pipe']
1330
+ }).trim();
1331
+
1332
+ const status = execSync('git status --porcelain', {
1333
+ encoding: 'utf-8',
1334
+ stdio: ['pipe', 'pipe', 'pipe']
1335
+ });
1336
+
1337
+ const uncommitted = status.split('\n').filter(Boolean).length;
1338
+
1339
+ return {
1340
+ isRepo: true,
1341
+ branch,
1342
+ uncommitted,
1343
+ clean: uncommitted === 0
1344
+ };
1345
+ } catch (err) {
1346
+ return { isRepo: true, error: err.message };
1347
+ }
1348
+ }
1349
+
1350
+ // ============================================================
1351
+ // Directory Operations
1352
+ // ============================================================
1353
+
1354
+ /**
1355
+ * List directories in a path
1356
+ */
1357
+ function listDirs(dirPath) {
1358
+ try {
1359
+ if (!dirExists(dirPath)) return [];
1360
+ return fs.readdirSync(dirPath)
1361
+ .filter(name => {
1362
+ const fullPath = path.join(dirPath, name);
1363
+ return fs.statSync(fullPath).isDirectory();
1364
+ });
1365
+ } catch {
1366
+ return [];
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * List files matching a pattern in a directory
1372
+ */
1373
+ function listFiles(dirPath, extension = null) {
1374
+ try {
1375
+ if (!dirExists(dirPath)) return [];
1376
+ return fs.readdirSync(dirPath)
1377
+ .filter(name => {
1378
+ const fullPath = path.join(dirPath, name);
1379
+ if (!fs.statSync(fullPath).isFile()) return false;
1380
+ if (extension && !name.endsWith(extension)) return false;
1381
+ return true;
1382
+ });
1383
+ } catch {
1384
+ return [];
1385
+ }
1386
+ }
1387
+
1388
+ /**
1389
+ * Count files recursively with depth limit and symlink protection
1390
+ */
1391
+ function countFiles(dirPath, extensions = [], maxDepth = 10) {
1392
+ let count = 0;
1393
+ const visited = new Set(); // Prevent infinite loops from symlinks
1394
+
1395
+ function walk(dir, depth) {
1396
+ if (depth <= 0) return; // Depth limit reached
1397
+
1398
+ try {
1399
+ // Resolve real path to detect symlink cycles
1400
+ const realPath = fs.realpathSync(dir);
1401
+ if (visited.has(realPath)) return; // Already visited (symlink cycle)
1402
+ visited.add(realPath);
1403
+
1404
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1405
+ for (const entry of entries) {
1406
+ // Skip node_modules and hidden directories for performance
1407
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
1408
+
1409
+ const fullPath = path.join(dir, entry.name);
1410
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
1411
+ walk(fullPath, depth - 1);
1412
+ } else if (entry.isFile()) {
1413
+ if (extensions.length === 0 || extensions.some(ext => entry.name.endsWith(ext))) {
1414
+ count++;
1415
+ }
1416
+ }
1417
+ }
1418
+ } catch (err) {
1419
+ // Ignore permission errors, log others in debug mode
1420
+ if (process.env.DEBUG) console.error(`[DEBUG] countFiles: ${err.message}`);
1421
+ }
1422
+ }
1423
+
1424
+ if (dirExists(dirPath)) {
1425
+ walk(dirPath, maxDepth);
1426
+ }
1427
+
1428
+ return count;
1429
+ }
1430
+
1431
+ // ============================================================
1432
+ // File Locking (for parallel execution safety)
1433
+ // ============================================================
1434
+
1435
+ /**
1436
+ * Simple file locking without external dependencies.
1437
+ * Uses mkdir (atomic on most filesystems) for lock acquisition.
1438
+ *
1439
+ * @param {string} filePath - File to lock
1440
+ * @param {Object} options - Lock options
1441
+ * @param {number} [options.retries=5] - Number of retry attempts
1442
+ * @param {number} [options.retryDelay=100] - Delay between retries (ms)
1443
+ * @param {number} [options.staleMs=30000] - Consider lock stale after this many ms
1444
+ * @returns {Promise<Function>} Release function
1445
+ */
1446
+ async function acquireLock(filePath, options = {}) {
1447
+ const {
1448
+ retries = LOCK_MAX_RETRIES,
1449
+ retryDelay = LOCK_RETRY_DELAY_MS,
1450
+ staleMs = LOCK_STALE_THRESHOLD_MS
1451
+ } = options;
1452
+
1453
+ const lockDir = `${filePath}.lock`;
1454
+ const lockInfoFile = path.join(lockDir, 'info.json');
1455
+ let staleCleanupAttempts = 0;
1456
+ const maxStaleCleanupAttempts = 3;
1457
+
1458
+ for (let attempt = 0; attempt <= retries; attempt++) {
1459
+ try {
1460
+ // mkdir is atomic - will fail if directory already exists
1461
+ fs.mkdirSync(lockDir, { recursive: false });
1462
+
1463
+ // Write lock info for stale detection
1464
+ fs.writeFileSync(lockInfoFile, JSON.stringify({
1465
+ pid: process.pid,
1466
+ timestamp: Date.now(),
1467
+ file: filePath
1468
+ }));
1469
+
1470
+ // Return release function with robust cleanup
1471
+ return () => {
1472
+ // Try to remove info file first
1473
+ try {
1474
+ fs.unlinkSync(lockInfoFile);
1475
+ } catch (unlinkErr) {
1476
+ // ENOENT is fine - file already gone
1477
+ // Other errors we log but continue to try rmdir
1478
+ if (unlinkErr.code !== 'ENOENT' && process.env.DEBUG) {
1479
+ console.warn(`[DEBUG] Lock info cleanup warning: ${unlinkErr.message}`);
1480
+ }
1481
+ }
1482
+
1483
+ // Always try to remove lock directory
1484
+ try {
1485
+ fs.rmdirSync(lockDir);
1486
+ } catch (rmdirErr) {
1487
+ // ENOENT is fine - directory already gone
1488
+ if (rmdirErr.code !== 'ENOENT') {
1489
+ // Directory not empty or other error - force cleanup
1490
+ try {
1491
+ fs.rmSync(lockDir, { recursive: true, force: true });
1492
+ } catch {
1493
+ // Last resort failed - log if debug
1494
+ if (process.env.DEBUG) {
1495
+ console.warn(`[DEBUG] Lock dir cleanup failed: ${rmdirErr.message}`);
1496
+ }
1497
+ }
1498
+ }
1499
+ }
1500
+ };
1501
+ } catch (err) {
1502
+ if (err.code === 'EEXIST') {
1503
+ // Lock exists - check if stale
1504
+ let isStale = false;
1505
+ let lockAge = 0;
1506
+
1507
+ try {
1508
+ const info = JSON.parse(fs.readFileSync(lockInfoFile, 'utf-8'));
1509
+ lockAge = Date.now() - info.timestamp;
1510
+ isStale = lockAge > staleMs;
1511
+ } catch {
1512
+ // Can't read lock info - assume stale if we've waited long enough
1513
+ isStale = attempt >= 2;
1514
+ }
1515
+
1516
+ if (isStale) {
1517
+ staleCleanupAttempts++;
1518
+ if (staleCleanupAttempts > maxStaleCleanupAttempts) {
1519
+ throw new Error(`Failed to clean up stale lock for ${filePath} after ${maxStaleCleanupAttempts} attempts`);
1520
+ }
1521
+
1522
+ if (process.env.DEBUG) {
1523
+ console.warn(`[DEBUG] Removing stale lock (${lockAge}ms old) for ${filePath} (cleanup attempt ${staleCleanupAttempts})`);
1524
+ }
1525
+
1526
+ try {
1527
+ fs.unlinkSync(lockInfoFile);
1528
+ fs.rmdirSync(lockDir);
1529
+ } catch (cleanupErr) {
1530
+ // Cleanup failed - wait before retrying
1531
+ if (process.env.DEBUG) {
1532
+ console.warn(`[DEBUG] Stale lock cleanup failed: ${cleanupErr.message}`);
1533
+ }
1534
+ await new Promise(resolve => setTimeout(resolve, retryDelay));
1535
+ }
1536
+ // Try again
1537
+ continue;
1538
+ }
1539
+
1540
+ if (attempt < retries) {
1541
+ // Wait and retry with exponential backoff
1542
+ await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
1543
+ continue;
1544
+ }
1545
+ }
1546
+
1547
+ throw new Error(`Failed to acquire lock for ${filePath}: ${err.message}`);
1548
+ }
1549
+ }
1550
+
1551
+ throw new Error(`Failed to acquire lock for ${filePath} after ${retries} retries`);
1552
+ }
1553
+
1554
+ /**
1555
+ * Execute a function while holding a lock on a file
1556
+ *
1557
+ * @param {string} filePath - File to lock
1558
+ * @param {Function} fn - Async function to execute
1559
+ * @param {Object} [options] - Lock options
1560
+ * @returns {Promise<*>} Result of fn
1561
+ *
1562
+ * @example
1563
+ * const data = await withLock(PATHS.ready, async () => {
1564
+ * const current = readJson(PATHS.ready);
1565
+ * current.tasks.push(newTask);
1566
+ * writeJson(PATHS.ready, current);
1567
+ * return current;
1568
+ * });
1569
+ */
1570
+ async function withLock(filePath, fn, options = {}) {
1571
+ const release = await acquireLock(filePath, options);
1572
+ try {
1573
+ return await fn();
1574
+ } finally {
1575
+ release();
1576
+ }
1577
+ }
1578
+
1579
+ /**
1580
+ * Synchronous version of withLock for simpler use cases
1581
+ * Note: Still uses async for lock acquisition, but fn is sync
1582
+ */
1583
+ async function withLockSync(filePath, fn, options = {}) {
1584
+ const release = await acquireLock(filePath, options);
1585
+ try {
1586
+ return fn();
1587
+ } finally {
1588
+ release();
1589
+ }
1590
+ }
1591
+
1592
+ /**
1593
+ * Clean up any stale locks in a directory
1594
+ * Useful for cleanup after crashes
1595
+ *
1596
+ * @param {string} dirPath - Directory to scan for .lock directories
1597
+ * @param {number} [staleMs=30000] - Consider locks older than this as stale
1598
+ * @returns {number} Number of locks cleaned up
1599
+ */
1600
+ function cleanupStaleLocks(dirPath, staleMs = CLEANUP_LOCK_STALE_MS) {
1601
+ try {
1602
+ if (!dirExists(dirPath)) return 0;
1603
+
1604
+ let cleaned = 0;
1605
+ const entries = fs.readdirSync(dirPath);
1606
+
1607
+ for (const entry of entries) {
1608
+ if (!entry.endsWith('.lock')) continue;
1609
+
1610
+ const lockDir = path.join(dirPath, entry);
1611
+ const lockInfoFile = path.join(lockDir, 'info.json');
1612
+
1613
+ try {
1614
+ const info = JSON.parse(fs.readFileSync(lockInfoFile, 'utf-8'));
1615
+ const age = Date.now() - info.timestamp;
1616
+
1617
+ if (age > staleMs) {
1618
+ // Clean up stale lock
1619
+ try {
1620
+ fs.unlinkSync(lockInfoFile);
1621
+ } catch (unlinkErr) {
1622
+ if (unlinkErr.code !== 'ENOENT') {
1623
+ if (process.env.DEBUG) {
1624
+ console.warn(`[DEBUG] cleanupStaleLocks: Could not delete ${lockInfoFile}: ${unlinkErr.message}`);
1625
+ }
1626
+ }
1627
+ }
1628
+
1629
+ try {
1630
+ fs.rmdirSync(lockDir);
1631
+ cleaned++;
1632
+ } catch (rmdirErr) {
1633
+ if (rmdirErr.code !== 'ENOENT') {
1634
+ // Directory not empty or other error - force cleanup
1635
+ try {
1636
+ fs.rmSync(lockDir, { recursive: true, force: true });
1637
+ cleaned++;
1638
+ } catch (forceErr) {
1639
+ if (process.env.DEBUG) {
1640
+ console.warn(`[DEBUG] cleanupStaleLocks: Could not force delete ${lockDir}: ${forceErr.message}`);
1641
+ }
1642
+ }
1643
+ }
1644
+ }
1645
+ }
1646
+ } catch (readErr) {
1647
+ // Can't read lock info - try to remove based on directory mtime
1648
+ if (readErr.code === 'ENOENT') continue; // Lock already gone
1649
+
1650
+ try {
1651
+ const stat = fs.statSync(lockDir);
1652
+ const age = Date.now() - stat.mtimeMs;
1653
+ if (age > staleMs) {
1654
+ fs.rmSync(lockDir, { recursive: true, force: true });
1655
+ cleaned++;
1656
+ }
1657
+ } catch (statErr) {
1658
+ // Directory gone or inaccessible - skip
1659
+ if (statErr.code !== 'ENOENT' && process.env.DEBUG) {
1660
+ console.warn(`[DEBUG] cleanupStaleLocks: Could not stat ${lockDir}: ${statErr.message}`);
1661
+ }
1662
+ }
1663
+ }
1664
+ }
1665
+
1666
+ return cleaned;
1667
+ } catch (dirErr) {
1668
+ if (process.env.DEBUG) {
1669
+ console.warn(`[DEBUG] cleanupStaleLocks: Could not scan ${dirPath}: ${dirErr.message}`);
1670
+ }
1671
+ return 0;
1672
+ }
1673
+ }
1674
+
1675
+ // ============================================================
1676
+ // Permission Validation (Claude Code settings.local.json)
1677
+ // ============================================================
1678
+
1679
+ /**
1680
+ * Analyze permission rules for issues
1681
+ * @param {string[]} permissions - Array of permission rules
1682
+ * @returns {Object} Analysis result with duplicates, overbroad, shadowed
1683
+ */
1684
+ function analyzePermissions(permissions) {
1685
+ const result = {
1686
+ duplicates: [],
1687
+ overbroad: [],
1688
+ shadowed: [],
1689
+ total: permissions.length
1690
+ };
1691
+
1692
+ // Check for duplicates
1693
+ const seen = new Set();
1694
+ for (const perm of permissions) {
1695
+ if (seen.has(perm)) {
1696
+ result.duplicates.push(perm);
1697
+ }
1698
+ seen.add(perm);
1699
+ }
1700
+
1701
+ // Check for overly broad patterns
1702
+ const overbroadPatterns = ['Bash(*)', 'Edit(*)', 'Write(*)', 'Read(*)'];
1703
+ for (const perm of permissions) {
1704
+ if (overbroadPatterns.includes(perm)) {
1705
+ result.overbroad.push(perm);
1706
+ }
1707
+ }
1708
+
1709
+ // Check for shadowed rules (specific rules covered by wildcards)
1710
+ const wildcards = permissions.filter(p => p.includes('*'));
1711
+ const specific = permissions.filter(p => !p.includes('*'));
1712
+
1713
+ for (const spec of specific) {
1714
+ // Extract tool type and pattern
1715
+ const match = spec.match(/^(\w+)\((.+)\)$/);
1716
+ if (match) {
1717
+ const [, tool, pattern] = match;
1718
+ // Check if a wildcard covers this
1719
+ for (const wild of wildcards) {
1720
+ const wildMatch = wild.match(/^(\w+)\((.+)\)$/);
1721
+ if (wildMatch && wildMatch[1] === tool) {
1722
+ const wildPattern = wildMatch[2].replace(/\*/g, '.*');
1723
+ try {
1724
+ const regex = new RegExp(`^${wildPattern}$`);
1725
+ if (regex.test(pattern)) {
1726
+ result.shadowed.push({ specific: spec, wildcard: wild });
1727
+ break;
1728
+ }
1729
+ } catch {
1730
+ // Invalid regex, skip
1731
+ }
1732
+ }
1733
+ }
1734
+ }
1735
+ }
1736
+
1737
+ return result;
1738
+ }
1739
+
1740
+ /**
1741
+ * Validate permission rules and return issues
1742
+ * @param {string[]} permissions - Array of permission rules
1743
+ * @returns {Object} Validation result with issues and warnings
1744
+ */
1745
+ function validatePermissions(permissions) {
1746
+ const analysis = analyzePermissions(permissions);
1747
+
1748
+ const issues = [];
1749
+ const warnings = [];
1750
+
1751
+ // Critical: duplicates waste space
1752
+ if (analysis.duplicates.length > 0) {
1753
+ warnings.push({
1754
+ type: 'duplicate',
1755
+ message: `${analysis.duplicates.length} duplicate rule(s) found`,
1756
+ items: analysis.duplicates
1757
+ });
1758
+ }
1759
+
1760
+ // Critical: overly broad rules are security risks
1761
+ if (analysis.overbroad.length > 0) {
1762
+ issues.push({
1763
+ type: 'overbroad',
1764
+ severity: 'critical',
1765
+ message: `${analysis.overbroad.length} overly broad rule(s) found`,
1766
+ items: analysis.overbroad
1767
+ });
1768
+ }
1769
+
1770
+ // Info: shadowed rules are redundant but not harmful
1771
+ if (analysis.shadowed.length > 0) {
1772
+ warnings.push({
1773
+ type: 'shadowed',
1774
+ message: `${analysis.shadowed.length} rule(s) shadowed by wildcards (redundant)`,
1775
+ items: analysis.shadowed.map(s => s.specific)
1776
+ });
1777
+ }
1778
+
1779
+ return {
1780
+ valid: issues.length === 0,
1781
+ issues,
1782
+ warnings,
1783
+ analysis
1784
+ };
1785
+ }
1786
+
1787
+ // ============================================================
1788
+ // AST-Grep Integration
1789
+ // ============================================================
1790
+
1791
+ /**
1792
+ * Common AST patterns for code discovery
1793
+ */
1794
+ const AST_PATTERNS = {
1795
+ // React patterns
1796
+ reactComponent: 'function $NAME($PROPS) { return <$_>$$$</$_> }',
1797
+ reactArrowComponent: 'const $NAME = ($PROPS) => { return <$_>$$$</$_> }',
1798
+ useStateHook: 'const [$STATE, $SETTER] = useState($INIT)',
1799
+ useEffectHook: 'useEffect($FN, [$$$DEPS])',
1800
+ useCustomHook: 'const $RESULT = use$NAME($$$ARGS)',
1801
+
1802
+ // TypeScript patterns
1803
+ interfaceDefinition: 'interface $NAME { $$$ }',
1804
+ typeDefinition: 'type $NAME = $$$',
1805
+ exportedFunction: 'export function $NAME($$$PARAMS) { $$$ }',
1806
+ exportedConst: 'export const $NAME = $$$',
1807
+
1808
+ // Import patterns
1809
+ namedImport: 'import { $$$IMPORTS } from "$PATH"',
1810
+ defaultImport: 'import $NAME from "$PATH"',
1811
+
1812
+ // Class patterns
1813
+ classDefinition: 'class $NAME { $$$ }',
1814
+ classExtends: 'class $NAME extends $BASE { $$$ }'
1815
+ };
1816
+
1817
+ /**
1818
+ * Check if ast-grep CLI (sg) is available
1819
+ */
1820
+ function isAstGrepAvailable() {
1821
+ try {
1822
+ execSync('which sg', { stdio: 'ignore' });
1823
+ return true;
1824
+ } catch {
1825
+ return false;
1826
+ }
1827
+ }
1828
+
1829
+ /**
1830
+ * Search codebase using ast-grep for structural patterns
1831
+ * @param {string} pattern - AST pattern (e.g., "useState($INIT)")
1832
+ * @param {object} options - { lang, cwd, maxResults }
1833
+ * @returns {Array|null} Array of matches or null if ast-grep unavailable
1834
+ */
1835
+ function astGrepSearch(pattern, options = {}) {
1836
+ const {
1837
+ lang = 'typescript',
1838
+ cwd = PROJECT_ROOT,
1839
+ maxResults = 20,
1840
+ searchDir = 'src'
1841
+ } = options;
1842
+
1843
+ // Check if ast-grep is available
1844
+ if (!isAstGrepAvailable()) {
1845
+ return null;
1846
+ }
1847
+
1848
+ const searchPath = path.join(cwd, searchDir);
1849
+ if (!dirExists(searchPath)) {
1850
+ return [];
1851
+ }
1852
+
1853
+ try {
1854
+ const result = execSync(
1855
+ `sg --pattern "${pattern.replace(/"/g, '\\"')}" --lang ${lang} --json "${searchPath}"`,
1856
+ {
1857
+ encoding: 'utf-8',
1858
+ maxBuffer: 10 * 1024 * 1024,
1859
+ timeout: 30000
1860
+ }
1861
+ );
1862
+
1863
+ const matches = JSON.parse(result || '[]');
1864
+ return matches.slice(0, maxResults).map(m => ({
1865
+ file: path.relative(cwd, m.file || m.path),
1866
+ line: m.range?.start?.line ?? m.startLine ?? 0,
1867
+ endLine: m.range?.end?.line ?? m.endLine ?? 0,
1868
+ content: m.text || m.match,
1869
+ meta: m.metaVariables || {} // Captured $VARS
1870
+ }));
1871
+ } catch (err) {
1872
+ // Parse error, timeout, or no matches
1873
+ if (err.stdout) {
1874
+ try {
1875
+ const matches = JSON.parse(err.stdout);
1876
+ return matches.slice(0, maxResults).map(m => ({
1877
+ file: path.relative(cwd, m.file || m.path),
1878
+ line: m.range?.start?.line ?? 0,
1879
+ content: m.text || m.match,
1880
+ meta: m.metaVariables || {}
1881
+ }));
1882
+ } catch {
1883
+ // Ignore parse errors
1884
+ }
1885
+ }
1886
+ return [];
1887
+ }
1888
+ }
1889
+
1890
+ /**
1891
+ * Search for React components in the codebase
1892
+ * @param {object} options - Search options
1893
+ */
1894
+ function findReactComponents(options = {}) {
1895
+ const { maxResults = 10, cwd = PROJECT_ROOT } = options;
1896
+
1897
+ // Try function components first
1898
+ let results = astGrepSearch(AST_PATTERNS.reactComponent, { ...options, maxResults });
1899
+
1900
+ // If ast-grep not available, return null
1901
+ if (results === null) return null;
1902
+
1903
+ // Also search arrow function components
1904
+ const arrowResults = astGrepSearch(AST_PATTERNS.reactArrowComponent, { ...options, maxResults });
1905
+ if (arrowResults) {
1906
+ results = [...results, ...arrowResults];
1907
+ }
1908
+
1909
+ // Dedupe by file
1910
+ const seen = new Set();
1911
+ return results.filter(r => {
1912
+ if (seen.has(r.file)) return false;
1913
+ seen.add(r.file);
1914
+ return true;
1915
+ }).slice(0, maxResults);
1916
+ }
1917
+
1918
+ /**
1919
+ * Search for custom hooks in the codebase
1920
+ * @param {object} options - Search options
1921
+ */
1922
+ function findCustomHooks(options = {}) {
1923
+ const { maxResults = 10 } = options;
1924
+
1925
+ // Search for function use* pattern
1926
+ const results = astGrepSearch('function use$NAME($$$) { $$$ }', { ...options, maxResults });
1927
+
1928
+ if (results === null) return null;
1929
+
1930
+ return results.filter(r => {
1931
+ // Filter to only actual hook files
1932
+ const fileName = path.basename(r.file).toLowerCase();
1933
+ return fileName.startsWith('use') || fileName.includes('hook');
1934
+ });
1935
+ }
1936
+
1937
+ /**
1938
+ * Search for TypeScript interfaces/types
1939
+ * @param {string} namePattern - Optional name pattern to filter by
1940
+ * @param {object} options - Search options
1941
+ */
1942
+ function findTypeDefinitions(namePattern = null, options = {}) {
1943
+ const { maxResults = 10 } = options;
1944
+
1945
+ // Search interfaces
1946
+ let results = astGrepSearch(AST_PATTERNS.interfaceDefinition, { ...options, maxResults });
1947
+
1948
+ if (results === null) return null;
1949
+
1950
+ // Also search type aliases
1951
+ const typeResults = astGrepSearch(AST_PATTERNS.typeDefinition, { ...options, maxResults });
1952
+ if (typeResults) {
1953
+ results = [...results, ...typeResults];
1954
+ }
1955
+
1956
+ // Filter by name pattern if provided
1957
+ if (namePattern) {
1958
+ const regex = new RegExp(namePattern, 'i');
1959
+ results = results.filter(r => regex.test(r.content));
1960
+ }
1961
+
1962
+ return results.slice(0, maxResults);
1963
+ }
1964
+
1965
+ // ============================================================
1966
+ // Token Estimation
1967
+ // ============================================================
1968
+
1969
+ /**
1970
+ * Token estimation constants.
1971
+ */
1972
+ const TOKEN_ESTIMATION = {
1973
+ // Characters per token (varies by content type)
1974
+ CHARS_PER_TOKEN_CODE: 3, // Code is more token-dense
1975
+ CHARS_PER_TOKEN_TEXT: 4, // General text/prose
1976
+ CHARS_PER_TOKEN_MIXED: 3.5, // Mixed content
1977
+
1978
+ // Line-based estimation (for code files)
1979
+ TOKENS_PER_LINE: 8, // Average tokens per line of code
1980
+
1981
+ // Complexity multipliers for task estimation
1982
+ COMPLEXITY_MULTIPLIERS: {
1983
+ low: 100,
1984
+ medium: 500,
1985
+ high: 2000
1986
+ }
1987
+ };
1988
+
1989
+ /**
1990
+ * Estimate token count for text content.
1991
+ *
1992
+ * Unified token estimation supporting multiple use cases:
1993
+ * - Simple text estimation
1994
+ * - Code-aware estimation (different density)
1995
+ * - Hybrid char+line estimation
1996
+ * - Content type auto-detection
1997
+ *
1998
+ * @param {string} content - Text content to estimate
1999
+ * @param {Object} [options] - Estimation options
2000
+ * @param {boolean} [options.isCode] - Treat as code (3 chars/token vs 4)
2001
+ * @param {boolean} [options.detectCodeRatio] - Auto-detect code vs text ratio
2002
+ * @param {boolean} [options.useLineEstimate] - Include line-based estimation (for files)
2003
+ * @param {string} [options.complexity] - Add complexity multiplier (low/medium/high)
2004
+ * @returns {number} Estimated token count
2005
+ *
2006
+ * @example
2007
+ * // Simple estimation
2008
+ * estimateTokens('Hello world'); // ~3
2009
+ *
2010
+ * @example
2011
+ * // Code estimation
2012
+ * estimateTokens(codeContent, { isCode: true });
2013
+ *
2014
+ * @example
2015
+ * // File with auto-detection
2016
+ * estimateTokens(fileContent, { detectCodeRatio: true, useLineEstimate: true });
2017
+ */
2018
+ function estimateTokens(content, options = {}) {
2019
+ if (!content || typeof content !== 'string') return 0;
2020
+
2021
+ const {
2022
+ isCode = false,
2023
+ detectCodeRatio = false,
2024
+ useLineEstimate = false,
2025
+ complexity = null
2026
+ } = options;
2027
+
2028
+ let estimate;
2029
+
2030
+ if (detectCodeRatio) {
2031
+ // Auto-detect code vs text ratio
2032
+ const codeRatio = detectCodeContentRatio(content);
2033
+ const effectiveCharsPerToken =
2034
+ TOKEN_ESTIMATION.CHARS_PER_TOKEN_CODE * codeRatio +
2035
+ TOKEN_ESTIMATION.CHARS_PER_TOKEN_TEXT * (1 - codeRatio);
2036
+ estimate = Math.ceil(content.length / effectiveCharsPerToken);
2037
+ } else if (isCode) {
2038
+ estimate = Math.ceil(content.length / TOKEN_ESTIMATION.CHARS_PER_TOKEN_CODE);
2039
+ } else {
2040
+ estimate = Math.ceil(content.length / TOKEN_ESTIMATION.CHARS_PER_TOKEN_TEXT);
2041
+ }
2042
+
2043
+ // Optionally blend with line-based estimate (better for structured code)
2044
+ if (useLineEstimate) {
2045
+ const lineCount = content.split('\n').length;
2046
+ const lineEstimate = lineCount * TOKEN_ESTIMATION.TOKENS_PER_LINE;
2047
+ estimate = Math.ceil((estimate + lineEstimate) / 2);
2048
+ }
2049
+
2050
+ // Optionally add complexity multiplier (for task estimation)
2051
+ if (complexity && TOKEN_ESTIMATION.COMPLEXITY_MULTIPLIERS[complexity]) {
2052
+ estimate += TOKEN_ESTIMATION.COMPLEXITY_MULTIPLIERS[complexity];
2053
+ }
2054
+
2055
+ return estimate;
2056
+ }
2057
+
2058
+ /**
2059
+ * Detect the ratio of code content in text (0 to 1).
2060
+ * Uses heuristics like brackets, semicolons, and code block markers.
2061
+ *
2062
+ * @param {string} content - Content to analyze
2063
+ * @returns {number} Code ratio from 0 (all prose) to 1 (all code)
2064
+ */
2065
+ function detectCodeContentRatio(content) {
2066
+ if (!content || content.length < 50) return 0;
2067
+
2068
+ // Check for code block markers (markdown)
2069
+ const codeBlockPattern = /```[\s\S]*?```/g;
2070
+ const inlineCodePattern = /`[^`]+`/g;
2071
+
2072
+ let codeChars = 0;
2073
+ const codeBlockMatches = content.match(codeBlockPattern);
2074
+ if (codeBlockMatches) {
2075
+ codeChars += codeBlockMatches.join('').length;
2076
+ }
2077
+ const inlineMatches = content.match(inlineCodePattern);
2078
+ if (inlineMatches) {
2079
+ codeChars += inlineMatches.join('').length;
2080
+ }
2081
+
2082
+ // Check for code indicators (brackets, semicolons, etc.)
2083
+ const codeIndicators = (content.match(/[{}\[\]();=<>]/g) || []).length;
2084
+ const indicatorRatio = codeIndicators / content.length;
2085
+
2086
+ // Combine code block ratio and indicator ratio
2087
+ const blockRatio = codeChars / content.length;
2088
+ const combinedRatio = Math.min(1, blockRatio + indicatorRatio * 2);
2089
+
2090
+ return combinedRatio;
2091
+ }
2092
+
2093
+ /**
2094
+ * Check if content is primarily code (helper for isCode parameter).
2095
+ *
2096
+ * @param {string} content - Content to check
2097
+ * @returns {boolean} True if content appears to be code
2098
+ */
2099
+ function isCodeContent(content) {
2100
+ return detectCodeContentRatio(content) > 0.3;
2101
+ }
2102
+
2103
+ // ============================================================
2104
+ // Exports
2105
+ // ============================================================
2106
+
2107
+ module.exports = {
2108
+ // Constants
2109
+ DEFAULT_COMMAND_TIMEOUT_MS,
2110
+ QUICK_COMMAND_TIMEOUT_MS,
2111
+ LOCK_STALE_THRESHOLD_MS,
2112
+ CLEANUP_LOCK_STALE_MS,
2113
+ LOCK_RETRY_DELAY_MS,
2114
+ LOCK_MAX_RETRIES,
2115
+ MAX_SESSION_HISTORY,
2116
+ MAX_WORKFLOW_ITERATIONS,
2117
+
2118
+ // Paths
2119
+ PATHS,
2120
+ PROJECT_ROOT,
2121
+ WORKFLOW_DIR,
2122
+ STATE_DIR,
2123
+ CLAUDE_DIR,
2124
+ getProjectRoot,
2125
+
2126
+ // Colors & Output
2127
+ colors,
2128
+ color,
2129
+ print,
2130
+ printHeader,
2131
+ printSection,
2132
+ success,
2133
+ warn,
2134
+ error,
2135
+ info,
2136
+
2137
+ // Task ID Generation (v1.9.0)
2138
+ generateTaskId,
2139
+ validateTaskId,
2140
+ isLegacyTaskId,
2141
+
2142
+ // JSON Output & CLI Flags (v1.9.0)
2143
+ outputJson,
2144
+ parseFlags,
2145
+
2146
+ // File Operations
2147
+ fileExists,
2148
+ dirExists,
2149
+ ensureDir: require('./flow-file-ops').ensureDir,
2150
+ readJson,
2151
+ writeJson,
2152
+ safeJsonParse,
2153
+ readFile,
2154
+ writeFile,
2155
+ validateJson,
2156
+ isPathWithinProject,
2157
+
2158
+ // Token Estimation
2159
+ TOKEN_ESTIMATION,
2160
+ estimateTokens,
2161
+ detectCodeContentRatio,
2162
+ isCodeContent,
2163
+
2164
+ // Config
2165
+ getConfig,
2166
+ getRawConfig, // Raw config without substitution (for editing)
2167
+ getConfigValue,
2168
+ setConfigValue, // Async with locking
2169
+ setConfigValueSync, // Sync without locking (use when already locked)
2170
+ resolveConfigValue, // Resolve {env:VAR} and {file:path} patterns
2171
+ invalidateConfigCache,
2172
+ validateConfig,
2173
+ KNOWN_CONFIG_KEYS,
2174
+
2175
+ // Ready.json
2176
+ getReadyData,
2177
+ validateReadyJson,
2178
+ saveReadyData,
2179
+ saveReadyDataAsync, // Async with locking
2180
+ findTask,
2181
+ moveTask,
2182
+ moveTaskAsync, // Async with locking
2183
+ getTaskCounts,
2184
+
2185
+ // Request Log
2186
+ countRequestLogEntries,
2187
+ getLastRequestLogEntry,
2188
+ getHighestRequestId,
2189
+ getNextRequestId,
2190
+ addRequestLogEntry,
2191
+
2192
+ // App Map
2193
+ countAppMapComponents,
2194
+ addAppMapComponent,
2195
+
2196
+ // Git
2197
+ isGitRepo,
2198
+ getGitStatus,
2199
+
2200
+ // Directory
2201
+ listDirs,
2202
+ listFiles,
2203
+ countFiles,
2204
+
2205
+ // File Locking
2206
+ acquireLock,
2207
+ withLock,
2208
+ withLockSync,
2209
+ cleanupStaleLocks,
2210
+
2211
+ // Permission Validation
2212
+ analyzePermissions,
2213
+ validatePermissions,
2214
+
2215
+ // AST-Grep Integration
2216
+ AST_PATTERNS,
2217
+ isAstGrepAvailable,
2218
+ astGrepSearch,
2219
+ findReactComponents,
2220
+ findCustomHooks,
2221
+ findTypeDefinitions,
2222
+ };
2223
+
2224
+ // ============================================================
2225
+ // Automatic Stale Lock Cleanup on Module Load
2226
+ // ============================================================
2227
+
2228
+ // Clean up any stale locks from previous sessions/crashes
2229
+ // This runs once when the module is first required
2230
+ (function autoCleanupStaleLocks() {
2231
+ try {
2232
+ // Only clean up if STATE_DIR exists (workflow initialized)
2233
+ if (dirExists(STATE_DIR)) {
2234
+ const cleaned = cleanupStaleLocks(STATE_DIR, 60000); // 60s stale threshold
2235
+ if (cleaned > 0 && process.env.DEBUG) {
2236
+ console.log(`[DEBUG] Auto-cleaned ${cleaned} stale lock(s) from ${STATE_DIR}`);
2237
+ }
2238
+ }
2239
+ } catch {
2240
+ // Silent failure - don't break module loading
2241
+ }
2242
+ })();