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.
- package/.workflow/agents/reviewer.md +81 -0
- package/.workflow/agents/security.md +94 -0
- package/.workflow/agents/story-writer.md +58 -0
- package/.workflow/bridges/base-bridge.js +395 -0
- package/.workflow/bridges/claude-bridge.js +434 -0
- package/.workflow/bridges/index.js +130 -0
- package/.workflow/lib/assumption-detector.js +481 -0
- package/.workflow/lib/config-substitution.js +371 -0
- package/.workflow/lib/failure-categories.js +478 -0
- package/.workflow/state/app-map.md.template +15 -0
- package/.workflow/state/architecture.md.template +24 -0
- package/.workflow/state/component-index.json.template +5 -0
- package/.workflow/state/decisions.md.template +15 -0
- package/.workflow/state/feedback-patterns.md.template +9 -0
- package/.workflow/state/knowledge-sync.json.template +6 -0
- package/.workflow/state/progress.md.template +14 -0
- package/.workflow/state/ready.json.template +7 -0
- package/.workflow/state/request-log.md.template +14 -0
- package/.workflow/state/session-state.json.template +11 -0
- package/.workflow/state/stack.md.template +33 -0
- package/.workflow/state/testing.md.template +36 -0
- package/.workflow/templates/claude-md.hbs +257 -0
- package/.workflow/templates/correction-report.md +67 -0
- package/.workflow/templates/gemini-md.hbs +52 -0
- package/README.md +1802 -0
- package/bin/flow +205 -0
- package/lib/index.js +33 -0
- package/lib/installer.js +467 -0
- package/lib/release-channel.js +269 -0
- package/lib/skill-registry.js +526 -0
- package/lib/upgrader.js +401 -0
- package/lib/utils.js +305 -0
- package/package.json +64 -0
- package/scripts/flow +985 -0
- package/scripts/flow-adaptive-learning.js +1259 -0
- package/scripts/flow-aggregate.js +488 -0
- package/scripts/flow-archive +133 -0
- package/scripts/flow-auto-context.js +1015 -0
- package/scripts/flow-auto-learn.js +615 -0
- package/scripts/flow-bridge.js +223 -0
- package/scripts/flow-browser-suggest.js +316 -0
- package/scripts/flow-bug.js +247 -0
- package/scripts/flow-cascade.js +711 -0
- package/scripts/flow-changelog +85 -0
- package/scripts/flow-checkpoint.js +483 -0
- package/scripts/flow-cli.js +403 -0
- package/scripts/flow-code-intelligence.js +760 -0
- package/scripts/flow-complexity.js +502 -0
- package/scripts/flow-config-set.js +152 -0
- package/scripts/flow-constants.js +157 -0
- package/scripts/flow-context +152 -0
- package/scripts/flow-context-init.js +482 -0
- package/scripts/flow-context-monitor.js +384 -0
- package/scripts/flow-context-scoring.js +886 -0
- package/scripts/flow-correct.js +458 -0
- package/scripts/flow-damage-control.js +985 -0
- package/scripts/flow-deps +101 -0
- package/scripts/flow-diff.js +700 -0
- package/scripts/flow-done +151 -0
- package/scripts/flow-done.js +489 -0
- package/scripts/flow-durable-session.js +1541 -0
- package/scripts/flow-entropy-monitor.js +345 -0
- package/scripts/flow-export-profile +349 -0
- package/scripts/flow-export-scanner.js +1046 -0
- package/scripts/flow-figma-confirm.js +400 -0
- package/scripts/flow-figma-extract.js +496 -0
- package/scripts/flow-figma-generate.js +683 -0
- package/scripts/flow-figma-index.js +909 -0
- package/scripts/flow-figma-match.js +617 -0
- package/scripts/flow-figma-mcp-server.js +518 -0
- package/scripts/flow-figma-pipeline.js +414 -0
- package/scripts/flow-file-ops.js +301 -0
- package/scripts/flow-gate-confidence.js +825 -0
- package/scripts/flow-guided-edit.js +659 -0
- package/scripts/flow-health +185 -0
- package/scripts/flow-health.js +413 -0
- package/scripts/flow-hooks.js +556 -0
- package/scripts/flow-http-client.js +249 -0
- package/scripts/flow-hybrid-detect.js +167 -0
- package/scripts/flow-hybrid-interactive.js +591 -0
- package/scripts/flow-hybrid-test.js +152 -0
- package/scripts/flow-import-profile +439 -0
- package/scripts/flow-init +253 -0
- package/scripts/flow-instruction-richness.js +827 -0
- package/scripts/flow-jira-integration.js +579 -0
- package/scripts/flow-knowledge-router.js +522 -0
- package/scripts/flow-knowledge-sync.js +589 -0
- package/scripts/flow-linear-integration.js +631 -0
- package/scripts/flow-links.js +774 -0
- package/scripts/flow-log-manager.js +559 -0
- package/scripts/flow-loop-enforcer.js +1246 -0
- package/scripts/flow-loop-retry-learning.js +630 -0
- package/scripts/flow-lsp.js +923 -0
- package/scripts/flow-map-index +348 -0
- package/scripts/flow-map-sync +201 -0
- package/scripts/flow-memory-blocks.js +668 -0
- package/scripts/flow-memory-compactor.js +350 -0
- package/scripts/flow-memory-db.js +1110 -0
- package/scripts/flow-memory-sync.js +484 -0
- package/scripts/flow-metrics.js +353 -0
- package/scripts/flow-migrate-ids.js +370 -0
- package/scripts/flow-model-adapter.js +802 -0
- package/scripts/flow-model-router.js +884 -0
- package/scripts/flow-models.js +1231 -0
- package/scripts/flow-morning.js +517 -0
- package/scripts/flow-multi-approach.js +660 -0
- package/scripts/flow-new-feature +86 -0
- package/scripts/flow-onboard +1042 -0
- package/scripts/flow-orchestrate-llm.js +459 -0
- package/scripts/flow-orchestrate.js +3592 -0
- package/scripts/flow-output.js +123 -0
- package/scripts/flow-parallel-detector.js +399 -0
- package/scripts/flow-parallel-dispatch.js +987 -0
- package/scripts/flow-parallel.js +428 -0
- package/scripts/flow-pattern-enforcer.js +600 -0
- package/scripts/flow-prd-manager.js +282 -0
- package/scripts/flow-progress.js +323 -0
- package/scripts/flow-project-analyzer.js +975 -0
- package/scripts/flow-prompt-composer.js +487 -0
- package/scripts/flow-providers.js +1381 -0
- package/scripts/flow-queue.js +308 -0
- package/scripts/flow-ready +82 -0
- package/scripts/flow-ready.js +189 -0
- package/scripts/flow-regression.js +396 -0
- package/scripts/flow-response-parser.js +450 -0
- package/scripts/flow-resume.js +284 -0
- package/scripts/flow-rules-sync.js +439 -0
- package/scripts/flow-run-trace.js +718 -0
- package/scripts/flow-safety.js +587 -0
- package/scripts/flow-search +104 -0
- package/scripts/flow-security.js +481 -0
- package/scripts/flow-session-end +106 -0
- package/scripts/flow-session-end.js +437 -0
- package/scripts/flow-session-state.js +671 -0
- package/scripts/flow-setup-hooks +216 -0
- package/scripts/flow-setup-hooks.js +377 -0
- package/scripts/flow-skill-create.js +329 -0
- package/scripts/flow-skill-creator.js +572 -0
- package/scripts/flow-skill-generator.js +1046 -0
- package/scripts/flow-skill-learn.js +880 -0
- package/scripts/flow-skill-matcher.js +578 -0
- package/scripts/flow-spec-generator.js +820 -0
- package/scripts/flow-stack-wizard.js +895 -0
- package/scripts/flow-standup +162 -0
- package/scripts/flow-start +74 -0
- package/scripts/flow-start.js +235 -0
- package/scripts/flow-status +110 -0
- package/scripts/flow-status.js +301 -0
- package/scripts/flow-step-browser.js +83 -0
- package/scripts/flow-step-changelog.js +217 -0
- package/scripts/flow-step-comments.js +306 -0
- package/scripts/flow-step-complexity.js +234 -0
- package/scripts/flow-step-coverage.js +218 -0
- package/scripts/flow-step-knowledge.js +193 -0
- package/scripts/flow-step-pr-tests.js +364 -0
- package/scripts/flow-step-regression.js +89 -0
- package/scripts/flow-step-review.js +516 -0
- package/scripts/flow-step-security.js +162 -0
- package/scripts/flow-step-silent-failures.js +290 -0
- package/scripts/flow-step-simplifier.js +346 -0
- package/scripts/flow-story +105 -0
- package/scripts/flow-story.js +500 -0
- package/scripts/flow-suspend.js +252 -0
- package/scripts/flow-sync-daemon.js +654 -0
- package/scripts/flow-task-analyzer.js +606 -0
- package/scripts/flow-team-dashboard.js +748 -0
- package/scripts/flow-team-sync.js +752 -0
- package/scripts/flow-team.js +977 -0
- package/scripts/flow-tech-options.js +528 -0
- package/scripts/flow-templates.js +812 -0
- package/scripts/flow-tiered-learning.js +728 -0
- package/scripts/flow-trace +204 -0
- package/scripts/flow-transcript-chunking.js +1106 -0
- package/scripts/flow-transcript-digest.js +7918 -0
- package/scripts/flow-transcript-language.js +465 -0
- package/scripts/flow-transcript-parsing.js +1085 -0
- package/scripts/flow-transcript-stories.js +2194 -0
- package/scripts/flow-update-map +224 -0
- package/scripts/flow-utils.js +2242 -0
- package/scripts/flow-verification.js +644 -0
- package/scripts/flow-verify.js +1177 -0
- package/scripts/flow-voice-input.js +638 -0
- package/scripts/flow-watch +168 -0
- package/scripts/flow-workflow-steps.js +521 -0
- package/scripts/flow-workflow.js +1029 -0
- package/scripts/flow-worktree.js +489 -0
- package/scripts/hooks/adapters/base-adapter.js +102 -0
- package/scripts/hooks/adapters/claude-code.js +359 -0
- package/scripts/hooks/adapters/index.js +79 -0
- package/scripts/hooks/core/component-check.js +341 -0
- package/scripts/hooks/core/index.js +35 -0
- package/scripts/hooks/core/loop-check.js +241 -0
- package/scripts/hooks/core/session-context.js +294 -0
- package/scripts/hooks/core/task-gate.js +177 -0
- package/scripts/hooks/core/validation.js +230 -0
- package/scripts/hooks/entry/claude-code/post-tool-use.js +65 -0
- package/scripts/hooks/entry/claude-code/pre-tool-use.js +89 -0
- package/scripts/hooks/entry/claude-code/session-end.js +87 -0
- package/scripts/hooks/entry/claude-code/session-start.js +46 -0
- package/scripts/hooks/entry/claude-code/stop.js +43 -0
- package/scripts/postinstall.js +139 -0
- package/templates/browser-test-flow.json +56 -0
- package/templates/bug-report.md +43 -0
- package/templates/component-detail.md +42 -0
- package/templates/component.stories.tsx +49 -0
- package/templates/context/constraints.md +83 -0
- package/templates/context/conventions.md +177 -0
- package/templates/context/stack.md +60 -0
- package/templates/correction-report.md +90 -0
- package/templates/feature-proposal.md +35 -0
- package/templates/hybrid/_base.md +254 -0
- package/templates/hybrid/_patterns.md +45 -0
- package/templates/hybrid/create-component.md +127 -0
- package/templates/hybrid/create-file.md +56 -0
- package/templates/hybrid/create-hook.md +145 -0
- package/templates/hybrid/create-service.md +70 -0
- package/templates/hybrid/fix-bug.md +33 -0
- package/templates/hybrid/modify-file.md +55 -0
- package/templates/story.md +68 -0
- package/templates/task.json +56 -0
- 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
|
+
})();
|