agileflow 2.82.0 → 2.82.1

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/lib/colors.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * AgileFlow CLI - Shared Color Utilities
3
+ *
4
+ * Centralized ANSI color codes and formatting helpers.
5
+ * Uses 256-color palette for modern terminal support.
6
+ */
7
+
8
+ /**
9
+ * ANSI color codes for terminal output.
10
+ * Includes standard colors, 256-color palette, and brand colors.
11
+ */
12
+ const c = {
13
+ // Reset and modifiers
14
+ reset: '\x1b[0m',
15
+ bold: '\x1b[1m',
16
+ dim: '\x1b[2m',
17
+ italic: '\x1b[3m',
18
+ underline: '\x1b[4m',
19
+
20
+ // Standard ANSI colors (8 colors)
21
+ red: '\x1b[31m',
22
+ green: '\x1b[32m',
23
+ yellow: '\x1b[33m',
24
+ blue: '\x1b[34m',
25
+ magenta: '\x1b[35m',
26
+ cyan: '\x1b[36m',
27
+ white: '\x1b[37m',
28
+
29
+ // Bright variants
30
+ brightBlack: '\x1b[90m',
31
+ brightRed: '\x1b[91m',
32
+ brightGreen: '\x1b[92m',
33
+ brightYellow: '\x1b[93m',
34
+ brightBlue: '\x1b[94m',
35
+ brightMagenta: '\x1b[95m',
36
+ brightCyan: '\x1b[96m',
37
+ brightWhite: '\x1b[97m',
38
+
39
+ // 256-color palette (vibrant, modern look)
40
+ mintGreen: '\x1b[38;5;158m', // Healthy/success states
41
+ peach: '\x1b[38;5;215m', // Warning states
42
+ coral: '\x1b[38;5;203m', // Critical/error states
43
+ lightGreen: '\x1b[38;5;194m', // Session healthy
44
+ lightYellow: '\x1b[38;5;228m', // Session warning
45
+ lightPink: '\x1b[38;5;210m', // Session critical
46
+ skyBlue: '\x1b[38;5;117m', // Directories/paths, ready states
47
+ lavender: '\x1b[38;5;147m', // Model info, story IDs
48
+ softGold: '\x1b[38;5;222m', // Cost/money
49
+ teal: '\x1b[38;5;80m', // Pending states
50
+ slate: '\x1b[38;5;103m', // Secondary info
51
+ rose: '\x1b[38;5;211m', // Blocked/critical accent
52
+ amber: '\x1b[38;5;214m', // WIP/in-progress accent
53
+ powder: '\x1b[38;5;153m', // Labels/headers
54
+
55
+ // Brand color (#e8683a - burnt orange/terracotta)
56
+ brand: '\x1b[38;2;232;104;58m',
57
+ orange: '\x1b[38;2;232;104;58m', // Alias for brand color
58
+
59
+ // Background colors
60
+ bgRed: '\x1b[41m',
61
+ bgGreen: '\x1b[42m',
62
+ bgYellow: '\x1b[43m',
63
+ bgBlue: '\x1b[44m',
64
+ };
65
+
66
+ /**
67
+ * Box drawing characters for tables and borders.
68
+ */
69
+ const box = {
70
+ // Corners (rounded)
71
+ tl: '╭', // top-left
72
+ tr: '╮', // top-right
73
+ bl: '╰', // bottom-left
74
+ br: '╯', // bottom-right
75
+
76
+ // Lines
77
+ h: '─', // horizontal
78
+ v: '│', // vertical
79
+
80
+ // T-junctions
81
+ lT: '├', // left T
82
+ rT: '┤', // right T
83
+ tT: '┬', // top T
84
+ bT: '┴', // bottom T
85
+
86
+ // Cross
87
+ cross: '┼',
88
+
89
+ // Double line variants
90
+ dh: '═', // double horizontal
91
+ dv: '║', // double vertical
92
+ };
93
+
94
+ /**
95
+ * Status indicators with colors.
96
+ */
97
+ const status = {
98
+ success: `${c.green}✓${c.reset}`,
99
+ warning: `${c.yellow}⚠️${c.reset}`,
100
+ error: `${c.red}✗${c.reset}`,
101
+ info: `${c.cyan}ℹ${c.reset}`,
102
+ pending: `${c.dim}○${c.reset}`,
103
+ inProgress: `${c.yellow}◐${c.reset}`,
104
+ done: `${c.green}●${c.reset}`,
105
+ blocked: `${c.red}◆${c.reset}`,
106
+ };
107
+
108
+ /**
109
+ * Wrap text with color codes.
110
+ *
111
+ * @param {string} text - Text to colorize
112
+ * @param {string} color - Color code from `c` object
113
+ * @returns {string} Colorized text
114
+ */
115
+ function colorize(text, color) {
116
+ return `${color}${text}${c.reset}`;
117
+ }
118
+
119
+ /**
120
+ * Create a dim text string.
121
+ *
122
+ * @param {string} text - Text to dim
123
+ * @returns {string} Dimmed text
124
+ */
125
+ function dim(text) {
126
+ return colorize(text, c.dim);
127
+ }
128
+
129
+ /**
130
+ * Create a bold text string.
131
+ *
132
+ * @param {string} text - Text to bold
133
+ * @returns {string} Bold text
134
+ */
135
+ function bold(text) {
136
+ return colorize(text, c.bold);
137
+ }
138
+
139
+ /**
140
+ * Create success-colored text.
141
+ *
142
+ * @param {string} text - Text to color
143
+ * @returns {string} Green text
144
+ */
145
+ function success(text) {
146
+ return colorize(text, c.green);
147
+ }
148
+
149
+ /**
150
+ * Create warning-colored text.
151
+ *
152
+ * @param {string} text - Text to color
153
+ * @returns {string} Yellow text
154
+ */
155
+ function warning(text) {
156
+ return colorize(text, c.yellow);
157
+ }
158
+
159
+ /**
160
+ * Create error-colored text.
161
+ *
162
+ * @param {string} text - Text to color
163
+ * @returns {string} Red text
164
+ */
165
+ function error(text) {
166
+ return colorize(text, c.red);
167
+ }
168
+
169
+ /**
170
+ * Create brand-colored text.
171
+ *
172
+ * @param {string} text - Text to color
173
+ * @returns {string} Brand-colored text (#e8683a)
174
+ */
175
+ function brand(text) {
176
+ return colorize(text, c.brand);
177
+ }
178
+
179
+ module.exports = {
180
+ c,
181
+ box,
182
+ status,
183
+ colorize,
184
+ dim,
185
+ bold,
186
+ success,
187
+ warning,
188
+ error,
189
+ brand,
190
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,279 @@
1
+ /**
2
+ * errors.js - Safe error handling wrappers
3
+ *
4
+ * Provides consistent error handling patterns across AgileFlow scripts.
5
+ * All wrappers return Result objects: { ok: boolean, data?: T, error?: string }
6
+ *
7
+ * Usage:
8
+ * const { safeReadJSON, safeWriteJSON, safeExec } = require('../lib/errors');
9
+ *
10
+ * const result = safeReadJSON('/path/to/file.json');
11
+ * if (result.ok) {
12
+ * console.log(result.data);
13
+ * } else {
14
+ * console.error(result.error);
15
+ * }
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const { execSync } = require('child_process');
20
+
21
+ // Debug logging (opt-in via AGILEFLOW_DEBUG=1)
22
+ const DEBUG = process.env.AGILEFLOW_DEBUG === '1';
23
+
24
+ function debugLog(operation, details) {
25
+ if (DEBUG) {
26
+ const timestamp = new Date().toISOString();
27
+ console.error(`[${timestamp}] [errors.js] ${operation}:`, JSON.stringify(details));
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Safely read and parse a JSON file
33
+ * @param {string} filePath - Absolute path to JSON file
34
+ * @param {object} options - Optional settings
35
+ * @param {*} options.defaultValue - Value to return if file doesn't exist (makes missing file not an error)
36
+ * @returns {{ ok: boolean, data?: any, error?: string }}
37
+ */
38
+ function safeReadJSON(filePath, options = {}) {
39
+ const { defaultValue } = options;
40
+
41
+ try {
42
+ if (!fs.existsSync(filePath)) {
43
+ if (defaultValue !== undefined) {
44
+ debugLog('safeReadJSON', { filePath, status: 'missing, using default' });
45
+ return { ok: true, data: defaultValue };
46
+ }
47
+ const error = `File not found: ${filePath}`;
48
+ debugLog('safeReadJSON', { filePath, error });
49
+ return { ok: false, error };
50
+ }
51
+
52
+ const content = fs.readFileSync(filePath, 'utf8');
53
+ const data = JSON.parse(content);
54
+ debugLog('safeReadJSON', { filePath, status: 'success' });
55
+ return { ok: true, data };
56
+ } catch (err) {
57
+ const error = `Failed to read JSON from ${filePath}: ${err.message}`;
58
+ debugLog('safeReadJSON', { filePath, error: err.message });
59
+ return { ok: false, error };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Safely write data as JSON to a file
65
+ * @param {string} filePath - Absolute path to JSON file
66
+ * @param {any} data - Data to serialize as JSON
67
+ * @param {object} options - Optional settings
68
+ * @param {number} options.spaces - JSON indentation (default: 2)
69
+ * @param {boolean} options.createDir - Create parent directories if missing (default: false)
70
+ * @returns {{ ok: boolean, error?: string }}
71
+ */
72
+ function safeWriteJSON(filePath, data, options = {}) {
73
+ const { spaces = 2, createDir = false } = options;
74
+
75
+ try {
76
+ if (createDir) {
77
+ const dir = require('path').dirname(filePath);
78
+ if (!fs.existsSync(dir)) {
79
+ fs.mkdirSync(dir, { recursive: true });
80
+ debugLog('safeWriteJSON', { filePath, status: 'created directory', dir });
81
+ }
82
+ }
83
+
84
+ const content = JSON.stringify(data, null, spaces) + '\n';
85
+ fs.writeFileSync(filePath, content);
86
+ debugLog('safeWriteJSON', { filePath, status: 'success' });
87
+ return { ok: true };
88
+ } catch (err) {
89
+ const error = `Failed to write JSON to ${filePath}: ${err.message}`;
90
+ debugLog('safeWriteJSON', { filePath, error: err.message });
91
+ return { ok: false, error };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Safely read a text file
97
+ * @param {string} filePath - Absolute path to file
98
+ * @param {object} options - Optional settings
99
+ * @param {string} options.defaultValue - Value to return if file doesn't exist
100
+ * @returns {{ ok: boolean, data?: string, error?: string }}
101
+ */
102
+ function safeReadFile(filePath, options = {}) {
103
+ const { defaultValue } = options;
104
+
105
+ try {
106
+ if (!fs.existsSync(filePath)) {
107
+ if (defaultValue !== undefined) {
108
+ debugLog('safeReadFile', { filePath, status: 'missing, using default' });
109
+ return { ok: true, data: defaultValue };
110
+ }
111
+ const error = `File not found: ${filePath}`;
112
+ debugLog('safeReadFile', { filePath, error });
113
+ return { ok: false, error };
114
+ }
115
+
116
+ const data = fs.readFileSync(filePath, 'utf8');
117
+ debugLog('safeReadFile', { filePath, status: 'success' });
118
+ return { ok: true, data };
119
+ } catch (err) {
120
+ const error = `Failed to read file ${filePath}: ${err.message}`;
121
+ debugLog('safeReadFile', { filePath, error: err.message });
122
+ return { ok: false, error };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Safely write text to a file
128
+ * @param {string} filePath - Absolute path to file
129
+ * @param {string} content - Content to write
130
+ * @param {object} options - Optional settings
131
+ * @param {boolean} options.createDir - Create parent directories if missing (default: false)
132
+ * @returns {{ ok: boolean, error?: string }}
133
+ */
134
+ function safeWriteFile(filePath, content, options = {}) {
135
+ const { createDir = false } = options;
136
+
137
+ try {
138
+ if (createDir) {
139
+ const dir = require('path').dirname(filePath);
140
+ if (!fs.existsSync(dir)) {
141
+ fs.mkdirSync(dir, { recursive: true });
142
+ debugLog('safeWriteFile', { filePath, status: 'created directory', dir });
143
+ }
144
+ }
145
+
146
+ fs.writeFileSync(filePath, content);
147
+ debugLog('safeWriteFile', { filePath, status: 'success' });
148
+ return { ok: true };
149
+ } catch (err) {
150
+ const error = `Failed to write file ${filePath}: ${err.message}`;
151
+ debugLog('safeWriteFile', { filePath, error: err.message });
152
+ return { ok: false, error };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Safely execute a shell command
158
+ * @param {string} command - Shell command to execute
159
+ * @param {object} options - Optional settings
160
+ * @param {string} options.cwd - Working directory
161
+ * @param {number} options.timeout - Timeout in ms (default: 30000)
162
+ * @param {boolean} options.silent - Suppress stderr (default: false)
163
+ * @returns {{ ok: boolean, data?: string, error?: string, exitCode?: number }}
164
+ */
165
+ function safeExec(command, options = {}) {
166
+ const { cwd = process.cwd(), timeout = 30000, silent = false } = options;
167
+
168
+ try {
169
+ const output = execSync(command, {
170
+ cwd,
171
+ encoding: 'utf8',
172
+ timeout,
173
+ stdio: silent ? ['pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'inherit'],
174
+ });
175
+ debugLog('safeExec', { command: command.slice(0, 50), status: 'success' });
176
+ return { ok: true, data: output.trim() };
177
+ } catch (err) {
178
+ const exitCode = err.status || 1;
179
+ const error = `Command failed (exit ${exitCode}): ${command.slice(0, 100)}${err.message ? ` - ${err.message}` : ''}`;
180
+ debugLog('safeExec', { command: command.slice(0, 50), error: err.message, exitCode });
181
+ return { ok: false, error, exitCode };
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Safely check if a file or directory exists
187
+ * @param {string} path - Path to check
188
+ * @returns {{ ok: boolean, exists: boolean }}
189
+ */
190
+ function safeExists(path) {
191
+ try {
192
+ const exists = fs.existsSync(path);
193
+ return { ok: true, exists };
194
+ } catch (err) {
195
+ debugLog('safeExists', { path, error: err.message });
196
+ return { ok: true, exists: false };
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Safely create a directory
202
+ * @param {string} dirPath - Directory path to create
203
+ * @param {object} options - Optional settings
204
+ * @param {boolean} options.recursive - Create parent directories (default: true)
205
+ * @returns {{ ok: boolean, error?: string }}
206
+ */
207
+ function safeMkdir(dirPath, options = {}) {
208
+ const { recursive = true } = options;
209
+
210
+ try {
211
+ if (!fs.existsSync(dirPath)) {
212
+ fs.mkdirSync(dirPath, { recursive });
213
+ debugLog('safeMkdir', { dirPath, status: 'created' });
214
+ } else {
215
+ debugLog('safeMkdir', { dirPath, status: 'already exists' });
216
+ }
217
+ return { ok: true };
218
+ } catch (err) {
219
+ const error = `Failed to create directory ${dirPath}: ${err.message}`;
220
+ debugLog('safeMkdir', { dirPath, error: err.message });
221
+ return { ok: false, error };
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Wrap any function to catch errors and return Result
227
+ * @param {Function} fn - Function to wrap
228
+ * @param {string} operationName - Name for error messages
229
+ * @returns {Function} - Wrapped function returning { ok, data?, error? }
230
+ */
231
+ function wrapSafe(fn, operationName = 'operation') {
232
+ return function (...args) {
233
+ try {
234
+ const result = fn.apply(this, args);
235
+ return { ok: true, data: result };
236
+ } catch (err) {
237
+ const error = `${operationName} failed: ${err.message}`;
238
+ debugLog('wrapSafe', { operationName, error: err.message });
239
+ return { ok: false, error };
240
+ }
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Wrap an async function to catch errors and return Result
246
+ * @param {Function} fn - Async function to wrap
247
+ * @param {string} operationName - Name for error messages
248
+ * @returns {Function} - Wrapped async function returning { ok, data?, error? }
249
+ */
250
+ function wrapSafeAsync(fn, operationName = 'operation') {
251
+ return async function (...args) {
252
+ try {
253
+ const result = await fn.apply(this, args);
254
+ return { ok: true, data: result };
255
+ } catch (err) {
256
+ const error = `${operationName} failed: ${err.message}`;
257
+ debugLog('wrapSafeAsync', { operationName, error: err.message });
258
+ return { ok: false, error };
259
+ }
260
+ };
261
+ }
262
+
263
+ module.exports = {
264
+ // Core safe operations
265
+ safeReadJSON,
266
+ safeWriteJSON,
267
+ safeReadFile,
268
+ safeWriteFile,
269
+ safeExec,
270
+ safeExists,
271
+ safeMkdir,
272
+
273
+ // Utility wrappers
274
+ wrapSafe,
275
+ wrapSafeAsync,
276
+
277
+ // Debug helper
278
+ debugLog,
279
+ };
package/lib/paths.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * AgileFlow CLI - Shared Path Utilities
3
+ *
4
+ * Centralized path resolution functions used across scripts.
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Find the project root by looking for .agileflow directory.
12
+ * Walks up from current directory until .agileflow is found.
13
+ *
14
+ * @param {string} [startDir=process.cwd()] - Directory to start searching from
15
+ * @returns {string} Project root path, or startDir if not found
16
+ */
17
+ function getProjectRoot(startDir = process.cwd()) {
18
+ let dir = startDir;
19
+ while (!fs.existsSync(path.join(dir, '.agileflow')) && dir !== '/') {
20
+ dir = path.dirname(dir);
21
+ }
22
+ return dir !== '/' ? dir : startDir;
23
+ }
24
+
25
+ /**
26
+ * Get the .agileflow directory path.
27
+ *
28
+ * @param {string} [rootDir] - Project root (auto-detected if not provided)
29
+ * @returns {string} Path to .agileflow directory
30
+ */
31
+ function getAgileflowDir(rootDir) {
32
+ const root = rootDir || getProjectRoot();
33
+ return path.join(root, '.agileflow');
34
+ }
35
+
36
+ /**
37
+ * Get the .claude directory path.
38
+ *
39
+ * @param {string} [rootDir] - Project root (auto-detected if not provided)
40
+ * @returns {string} Path to .claude directory
41
+ */
42
+ function getClaudeDir(rootDir) {
43
+ const root = rootDir || getProjectRoot();
44
+ return path.join(root, '.claude');
45
+ }
46
+
47
+ /**
48
+ * Get the docs directory path.
49
+ *
50
+ * @param {string} [rootDir] - Project root (auto-detected if not provided)
51
+ * @returns {string} Path to docs directory
52
+ */
53
+ function getDocsDir(rootDir) {
54
+ const root = rootDir || getProjectRoot();
55
+ return path.join(root, 'docs');
56
+ }
57
+
58
+ /**
59
+ * Get the status.json path.
60
+ *
61
+ * @param {string} [rootDir] - Project root (auto-detected if not provided)
62
+ * @returns {string} Path to status.json
63
+ */
64
+ function getStatusPath(rootDir) {
65
+ const root = rootDir || getProjectRoot();
66
+ return path.join(root, 'docs', '09-agents', 'status.json');
67
+ }
68
+
69
+ /**
70
+ * Get the session-state.json path.
71
+ *
72
+ * @param {string} [rootDir] - Project root (auto-detected if not provided)
73
+ * @returns {string} Path to session-state.json
74
+ */
75
+ function getSessionStatePath(rootDir) {
76
+ const root = rootDir || getProjectRoot();
77
+ return path.join(root, 'docs', '09-agents', 'session-state.json');
78
+ }
79
+
80
+ /**
81
+ * Check if we're in an AgileFlow project.
82
+ *
83
+ * @param {string} [dir=process.cwd()] - Directory to check
84
+ * @returns {boolean} True if .agileflow directory exists
85
+ */
86
+ function isAgileflowProject(dir = process.cwd()) {
87
+ const root = getProjectRoot(dir);
88
+ return fs.existsSync(path.join(root, '.agileflow'));
89
+ }
90
+
91
+ module.exports = {
92
+ getProjectRoot,
93
+ getAgileflowDir,
94
+ getClaudeDir,
95
+ getDocsDir,
96
+ getStatusPath,
97
+ getSessionStatePath,
98
+ isAgileflowProject,
99
+ };
@@ -0,0 +1,337 @@
1
+ /**
2
+ * AgileFlow CLI - Input Validation Utilities
3
+ *
4
+ * Centralized validation patterns and helpers to prevent
5
+ * command injection and invalid input handling.
6
+ */
7
+
8
+ /**
9
+ * Validation patterns for common input types.
10
+ * All patterns use strict whitelisting approach.
11
+ */
12
+ const PATTERNS = {
13
+ // Git branch: alphanumeric, underscores, hyphens, forward slashes
14
+ // Examples: main, feature/US-0001, session-1, my_branch
15
+ branchName: /^[a-zA-Z0-9][a-zA-Z0-9_/-]*$/,
16
+
17
+ // Story ID: US-0001 to US-99999
18
+ storyId: /^US-\d{4,5}$/,
19
+
20
+ // Epic ID: EP-0001 to EP-99999
21
+ epicId: /^EP-\d{4,5}$/,
22
+
23
+ // Feature name: lowercase with hyphens, starts with letter
24
+ // Examples: damage-control, status-line, archival
25
+ featureName: /^[a-z][a-z0-9-]*$/,
26
+
27
+ // Profile name: alphanumeric with underscores/hyphens, starts with letter
28
+ // Examples: default, my-profile, dev_config
29
+ profileName: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
30
+
31
+ // Command name: alphanumeric with hyphens/colons, starts with letter
32
+ // Examples: babysit, story:list, agileflow:configure
33
+ commandName: /^[a-zA-Z][a-zA-Z0-9:-]*$/,
34
+
35
+ // Session nickname: alphanumeric with hyphens/underscores
36
+ // Examples: auth-work, feature_1, main
37
+ sessionNickname: /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
38
+
39
+ // Merge strategy: squash or merge
40
+ // Examples: squash, merge
41
+ mergeStrategy: /^(squash|merge)$/,
42
+ };
43
+
44
+ /**
45
+ * Validate a git branch name.
46
+ * @param {string} name - Branch name to validate
47
+ * @returns {boolean} True if valid
48
+ */
49
+ function isValidBranchName(name) {
50
+ if (!name || typeof name !== 'string') return false;
51
+ if (name.length > 255) return false; // Git limit
52
+ if (name.startsWith('-')) return false; // Prevent flag injection
53
+ if (name.includes('..')) return false; // Prevent path traversal
54
+ if (name.endsWith('.lock')) return false; // Reserved
55
+ return PATTERNS.branchName.test(name);
56
+ }
57
+
58
+ /**
59
+ * Validate a story ID (US-XXXX format).
60
+ * @param {string} id - Story ID to validate
61
+ * @returns {boolean} True if valid
62
+ */
63
+ function isValidStoryId(id) {
64
+ if (!id || typeof id !== 'string') return false;
65
+ return PATTERNS.storyId.test(id);
66
+ }
67
+
68
+ /**
69
+ * Validate an epic ID (EP-XXXX format).
70
+ * @param {string} id - Epic ID to validate
71
+ * @returns {boolean} True if valid
72
+ */
73
+ function isValidEpicId(id) {
74
+ if (!id || typeof id !== 'string') return false;
75
+ return PATTERNS.epicId.test(id);
76
+ }
77
+
78
+ /**
79
+ * Validate a feature name.
80
+ * @param {string} name - Feature name to validate
81
+ * @returns {boolean} True if valid
82
+ */
83
+ function isValidFeatureName(name) {
84
+ if (!name || typeof name !== 'string') return false;
85
+ if (name.length > 50) return false;
86
+ return PATTERNS.featureName.test(name);
87
+ }
88
+
89
+ /**
90
+ * Validate a profile name.
91
+ * @param {string} name - Profile name to validate
92
+ * @returns {boolean} True if valid
93
+ */
94
+ function isValidProfileName(name) {
95
+ if (!name || typeof name !== 'string') return false;
96
+ if (name.length > 50) return false;
97
+ return PATTERNS.profileName.test(name);
98
+ }
99
+
100
+ /**
101
+ * Validate a command name.
102
+ * @param {string} name - Command name to validate
103
+ * @returns {boolean} True if valid
104
+ */
105
+ function isValidCommandName(name) {
106
+ if (!name || typeof name !== 'string') return false;
107
+ if (name.length > 100) return false;
108
+ return PATTERNS.commandName.test(name);
109
+ }
110
+
111
+ /**
112
+ * Validate a session nickname.
113
+ * @param {string} name - Nickname to validate
114
+ * @returns {boolean} True if valid
115
+ */
116
+ function isValidSessionNickname(name) {
117
+ if (!name || typeof name !== 'string') return false;
118
+ if (name.length > 50) return false;
119
+ return PATTERNS.sessionNickname.test(name);
120
+ }
121
+
122
+ /**
123
+ * Validate a merge strategy (squash or merge).
124
+ * @param {string} strategy - Strategy to validate
125
+ * @returns {boolean} True if valid
126
+ */
127
+ function isValidMergeStrategy(strategy) {
128
+ if (!strategy || typeof strategy !== 'string') return false;
129
+ return PATTERNS.mergeStrategy.test(strategy);
130
+ }
131
+
132
+ /**
133
+ * Validate that a value is a positive integer within bounds.
134
+ * @param {any} val - Value to validate
135
+ * @param {number} min - Minimum allowed value (inclusive)
136
+ * @param {number} max - Maximum allowed value (inclusive)
137
+ * @returns {boolean} True if valid positive integer in range
138
+ */
139
+ function isPositiveInteger(val, min = 1, max = Number.MAX_SAFE_INTEGER) {
140
+ const num = typeof val === 'string' ? parseInt(val, 10) : val;
141
+ if (!Number.isInteger(num)) return false;
142
+ return num >= min && num <= max;
143
+ }
144
+
145
+ /**
146
+ * Parse and validate an integer with bounds.
147
+ * @param {any} val - Value to parse
148
+ * @param {number} defaultVal - Default if invalid
149
+ * @param {number} min - Minimum allowed value
150
+ * @param {number} max - Maximum allowed value
151
+ * @returns {number} Parsed integer or default
152
+ */
153
+ function parseIntBounded(val, defaultVal, min = 1, max = Number.MAX_SAFE_INTEGER) {
154
+ const num = typeof val === 'string' ? parseInt(val, 10) : val;
155
+ if (!Number.isInteger(num) || num < min || num > max) {
156
+ return defaultVal;
157
+ }
158
+ return num;
159
+ }
160
+
161
+ /**
162
+ * Validate an option key against an allowed whitelist.
163
+ * @param {string} key - Option key to validate
164
+ * @param {string[]} allowedKeys - Array of allowed keys
165
+ * @returns {boolean} True if key is in whitelist
166
+ */
167
+ function isValidOption(key, allowedKeys) {
168
+ if (!key || typeof key !== 'string') return false;
169
+ if (!Array.isArray(allowedKeys)) return false;
170
+ return allowedKeys.includes(key);
171
+ }
172
+
173
+ /**
174
+ * Validate and sanitize CLI arguments for known option types.
175
+ * Returns validated args object or null if critical validation fails.
176
+ *
177
+ * @param {string[]} args - Raw process.argv slice
178
+ * @param {Object} schema - Schema defining expected options
179
+ * @returns {{ ok: boolean, data?: Object, error?: string }}
180
+ *
181
+ * @example
182
+ * const schema = {
183
+ * branch: { type: 'branchName', required: true },
184
+ * max: { type: 'positiveInt', min: 1, max: 100, default: 20 },
185
+ * profile: { type: 'profileName', default: 'default' }
186
+ * };
187
+ * const result = validateArgs(args, schema);
188
+ */
189
+ function validateArgs(args, schema) {
190
+ const result = {};
191
+ const errors = [];
192
+
193
+ // Parse args into key-value pairs
194
+ const parsed = {};
195
+ for (let i = 0; i < args.length; i++) {
196
+ const arg = args[i];
197
+ if (arg.startsWith('--')) {
198
+ const [key, ...valueParts] = arg.slice(2).split('=');
199
+ const value = valueParts.length > 0 ? valueParts.join('=') : args[++i];
200
+ parsed[key] = value;
201
+ }
202
+ }
203
+
204
+ // Validate each schema field
205
+ for (const [field, config] of Object.entries(schema)) {
206
+ const value = parsed[field];
207
+
208
+ // Check required
209
+ if (config.required && (value === undefined || value === null)) {
210
+ errors.push(`Missing required option: --${field}`);
211
+ continue;
212
+ }
213
+
214
+ // Use default if not provided
215
+ if (value === undefined || value === null) {
216
+ if (config.default !== undefined) {
217
+ result[field] = config.default;
218
+ }
219
+ continue;
220
+ }
221
+
222
+ // Validate by type
223
+ switch (config.type) {
224
+ case 'branchName':
225
+ if (!isValidBranchName(value)) {
226
+ errors.push(`Invalid branch name: ${value}`);
227
+ } else {
228
+ result[field] = value;
229
+ }
230
+ break;
231
+
232
+ case 'storyId':
233
+ if (!isValidStoryId(value)) {
234
+ errors.push(`Invalid story ID: ${value} (expected US-XXXX)`);
235
+ } else {
236
+ result[field] = value;
237
+ }
238
+ break;
239
+
240
+ case 'epicId':
241
+ if (!isValidEpicId(value)) {
242
+ errors.push(`Invalid epic ID: ${value} (expected EP-XXXX)`);
243
+ } else {
244
+ result[field] = value;
245
+ }
246
+ break;
247
+
248
+ case 'featureName':
249
+ if (!isValidFeatureName(value)) {
250
+ errors.push(`Invalid feature name: ${value}`);
251
+ } else {
252
+ result[field] = value;
253
+ }
254
+ break;
255
+
256
+ case 'profileName':
257
+ if (!isValidProfileName(value)) {
258
+ errors.push(`Invalid profile name: ${value}`);
259
+ } else {
260
+ result[field] = value;
261
+ }
262
+ break;
263
+
264
+ case 'commandName':
265
+ if (!isValidCommandName(value)) {
266
+ errors.push(`Invalid command name: ${value}`);
267
+ } else {
268
+ result[field] = value;
269
+ }
270
+ break;
271
+
272
+ case 'sessionNickname':
273
+ if (!isValidSessionNickname(value)) {
274
+ errors.push(`Invalid session nickname: ${value}`);
275
+ } else {
276
+ result[field] = value;
277
+ }
278
+ break;
279
+
280
+ case 'positiveInt': {
281
+ const min = config.min || 1;
282
+ const max = config.max || Number.MAX_SAFE_INTEGER;
283
+ if (!isPositiveInteger(value, min, max)) {
284
+ errors.push(`Invalid integer for ${field}: ${value} (expected ${min}-${max})`);
285
+ result[field] = config.default;
286
+ } else {
287
+ result[field] = parseInt(value, 10);
288
+ }
289
+ break;
290
+ }
291
+
292
+ case 'enum':
293
+ if (!config.values.includes(value)) {
294
+ errors.push(
295
+ `Invalid value for ${field}: ${value} (expected: ${config.values.join(', ')})`
296
+ );
297
+ } else {
298
+ result[field] = value;
299
+ }
300
+ break;
301
+
302
+ case 'boolean':
303
+ result[field] = value === 'true' || value === '1' || value === true;
304
+ break;
305
+
306
+ case 'string':
307
+ // Basic string - just store it (caller should validate further if needed)
308
+ result[field] = String(value);
309
+ break;
310
+
311
+ default:
312
+ result[field] = value;
313
+ }
314
+ }
315
+
316
+ if (errors.length > 0) {
317
+ return { ok: false, error: errors.join('; ') };
318
+ }
319
+
320
+ return { ok: true, data: result };
321
+ }
322
+
323
+ module.exports = {
324
+ PATTERNS,
325
+ isValidBranchName,
326
+ isValidStoryId,
327
+ isValidEpicId,
328
+ isValidFeatureName,
329
+ isValidProfileName,
330
+ isValidCommandName,
331
+ isValidSessionNickname,
332
+ isValidMergeStrategy,
333
+ isPositiveInteger,
334
+ parseIntBounded,
335
+ isValidOption,
336
+ validateArgs,
337
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agileflow",
3
- "version": "2.82.0",
3
+ "version": "2.82.1",
4
4
  "description": "AI-driven agile development system for Claude Code, Cursor, Windsurf, and more",
5
5
  "keywords": [
6
6
  "agile",
@@ -32,6 +32,7 @@
32
32
  "tools/",
33
33
  "src/",
34
34
  "scripts/",
35
+ "lib/",
35
36
  "LICENSE",
36
37
  "README.md"
37
38
  ],
@@ -674,7 +674,9 @@ function handleLoop(rootDir) {
674
674
  const result = evaluateDiscretionCondition(condition, rootDir, ctx);
675
675
  discretionResults.push({ condition, ...result });
676
676
  const marker = result.passed ? `${c.green}✓` : `${c.yellow}⏳`;
677
- console.log(` ${marker} **${condition.replace(/\*\*/g, '')}**: ${result.message}${c.reset}`);
677
+ console.log(
678
+ ` ${marker} **${condition.replace(/\*\*/g, '')}**: ${result.message}${c.reset}`
679
+ );
678
680
  }
679
681
 
680
682
  // Track which conditions have been verified
@@ -688,8 +690,7 @@ function handleLoop(rootDir) {
688
690
  }
689
691
 
690
692
  // Check if all verification modes passed
691
- const allDiscretionPassed =
692
- !hasDiscretionConditions || discretionResults.every(r => r.passed);
693
+ const allDiscretionPassed = !hasDiscretionConditions || discretionResults.every(r => r.passed);
693
694
  const canComplete =
694
695
  testResult.passed &&
695
696
  (!visualMode || screenshotResult.passed) &&
@@ -848,7 +849,9 @@ function handleCLI() {
848
849
  const verified = loop.conditions_verified
849
850
  ? `${c.green}yes${c.reset}`
850
851
  : `${c.yellow}no${c.reset}`;
851
- console.log(` Discretion Conditions: ${loop.conditions.length} (All Verified: ${verified})`);
852
+ console.log(
853
+ ` Discretion Conditions: ${loop.conditions.length} (All Verified: ${verified})`
854
+ );
852
855
  for (const result of loop.condition_results || []) {
853
856
  const mark = result.passed ? `${c.green}✓${c.reset}` : `${c.yellow}⏳${c.reset}`;
854
857
  console.log(` ${mark} ${result.condition.replace(/\*\*/g, '')}`);
@@ -953,8 +956,7 @@ function handleCLI() {
953
956
  }
954
957
 
955
958
  // Get conditions from metadata if not provided via CLI
956
- const allConditions =
957
- conditions.length > 0 ? conditions : getDiscretionConditions(rootDir);
959
+ const allConditions = conditions.length > 0 ? conditions : getDiscretionConditions(rootDir);
958
960
 
959
961
  // Initialize loop state
960
962
  const state = getSessionState(rootDir);