agileflow 2.80.0 → 2.82.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.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * damage-control-utils.js - Shared utilities for damage-control hooks
3
+ *
4
+ * IMPORTANT: These scripts must FAIL OPEN (exit 0 on error)
5
+ * to avoid blocking users when config is broken.
6
+ *
7
+ * This module is copied to .agileflow/scripts/lib/ at install time
8
+ * and used by damage-control-bash.js, damage-control-edit.js, damage-control-write.js
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ // Inline colors (no external dependency - keeps scripts standalone)
16
+ const c = {
17
+ coral: '\x1b[38;5;203m',
18
+ dim: '\x1b[2m',
19
+ reset: '\x1b[0m',
20
+ };
21
+
22
+ // Shared constants
23
+ const CONFIG_PATHS = [
24
+ '.agileflow/config/damage-control-patterns.yaml',
25
+ '.agileflow/config/damage-control-patterns.yml',
26
+ '.agileflow/templates/damage-control-patterns.yaml',
27
+ ];
28
+
29
+ const STDIN_TIMEOUT_MS = 4000;
30
+
31
+ /**
32
+ * Find project root by looking for .agileflow directory
33
+ * @returns {string} Project root path or current working directory
34
+ */
35
+ function findProjectRoot() {
36
+ let dir = process.cwd();
37
+ while (dir !== '/') {
38
+ if (fs.existsSync(path.join(dir, '.agileflow'))) {
39
+ return dir;
40
+ }
41
+ dir = path.dirname(dir);
42
+ }
43
+ return process.cwd();
44
+ }
45
+
46
+ /**
47
+ * Expand ~ to home directory
48
+ * @param {string} p - Path that may start with ~/
49
+ * @returns {string} Expanded path
50
+ */
51
+ function expandPath(p) {
52
+ if (p.startsWith('~/')) {
53
+ return path.join(os.homedir(), p.slice(2));
54
+ }
55
+ return p;
56
+ }
57
+
58
+ /**
59
+ * Load patterns configuration from YAML file
60
+ * Returns empty config if not found (fail-open)
61
+ *
62
+ * @param {string} projectRoot - Project root directory
63
+ * @param {function} parseYAML - Function to parse YAML content
64
+ * @param {object} defaultConfig - Default config if no file found
65
+ * @returns {object} Parsed configuration
66
+ */
67
+ function loadPatterns(projectRoot, parseYAML, defaultConfig = {}) {
68
+ for (const configPath of CONFIG_PATHS) {
69
+ const fullPath = path.join(projectRoot, configPath);
70
+ if (fs.existsSync(fullPath)) {
71
+ try {
72
+ const content = fs.readFileSync(fullPath, 'utf8');
73
+ return parseYAML(content);
74
+ } catch (e) {
75
+ // Continue to next path
76
+ }
77
+ }
78
+ }
79
+
80
+ // Return empty config if no file found (fail-open)
81
+ return defaultConfig;
82
+ }
83
+
84
+ /**
85
+ * Check if a file path matches any of the protected patterns
86
+ *
87
+ * @param {string} filePath - File path to check
88
+ * @param {string[]} patterns - Array of patterns to match against
89
+ * @returns {string|null} Matched pattern or null
90
+ */
91
+ function pathMatches(filePath, patterns) {
92
+ if (!filePath) return null;
93
+
94
+ const normalizedPath = path.resolve(filePath);
95
+ const relativePath = path.relative(process.cwd(), normalizedPath);
96
+
97
+ for (const pattern of patterns) {
98
+ const expandedPattern = expandPath(pattern);
99
+
100
+ // Check if pattern is a directory prefix
101
+ if (pattern.endsWith('/')) {
102
+ const patternDir = expandedPattern.slice(0, -1);
103
+ if (normalizedPath.startsWith(patternDir)) {
104
+ return pattern;
105
+ }
106
+ }
107
+
108
+ // Check exact match
109
+ if (normalizedPath === expandedPattern) {
110
+ return pattern;
111
+ }
112
+
113
+ // Check if normalized path ends with pattern (for filenames like "id_rsa")
114
+ if (normalizedPath.endsWith(pattern) || relativePath.endsWith(pattern)) {
115
+ return pattern;
116
+ }
117
+
118
+ // Check if pattern appears in path (for patterns like "*.pem")
119
+ if (pattern.startsWith('*')) {
120
+ const ext = pattern.slice(1);
121
+ if (normalizedPath.endsWith(ext) || relativePath.endsWith(ext)) {
122
+ return pattern;
123
+ }
124
+ }
125
+
126
+ // Check if path contains pattern (for things like ".env.production")
127
+ const patternBase = path.basename(pattern);
128
+ if (path.basename(normalizedPath) === patternBase) {
129
+ return pattern;
130
+ }
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Output blocked message to stderr
138
+ *
139
+ * @param {string} reason - Main reason for blocking
140
+ * @param {string} [detail] - Additional detail
141
+ * @param {string} [context] - Context info (file path or command)
142
+ */
143
+ function outputBlocked(reason, detail, context) {
144
+ console.error(`${c.coral}[BLOCKED]${c.reset} ${reason}`);
145
+ if (detail) {
146
+ console.error(`${c.dim}${detail}${c.reset}`);
147
+ }
148
+ if (context) {
149
+ console.error(`${c.dim}${context}${c.reset}`);
150
+ }
151
+ // Help message for AI and user
152
+ console.error('');
153
+ console.error(
154
+ `${c.dim}This is intentional - AgileFlow Damage Control blocked a potentially dangerous operation.${c.reset}`
155
+ );
156
+ console.error(
157
+ `${c.dim}DO NOT retry this command. Ask the user if they want to proceed manually.${c.reset}`
158
+ );
159
+ console.error(`${c.dim}To disable: run /configure → Infrastructure → Damage Control${c.reset}`);
160
+ }
161
+
162
+ /**
163
+ * Run damage control hook with stdin parsing
164
+ * Handles common error cases and timeout
165
+ *
166
+ * @param {object} options - Hook options
167
+ * @param {function} options.getInputValue - Extract value from parsed input (input) => value
168
+ * @param {function} options.loadConfig - Load configuration () => config
169
+ * @param {function} options.validate - Validate input (value, config) => { action, reason, detail? }
170
+ * @param {function} options.onBlock - Handle blocked result (result, value) => void
171
+ * @param {function} [options.onAsk] - Handle ask result (result, value) => void (optional)
172
+ */
173
+ function runDamageControlHook(options) {
174
+ const { getInputValue, loadConfig, validate, onBlock, onAsk } = options;
175
+
176
+ let inputData = '';
177
+
178
+ process.stdin.setEncoding('utf8');
179
+
180
+ process.stdin.on('data', chunk => {
181
+ inputData += chunk;
182
+ });
183
+
184
+ process.stdin.on('end', () => {
185
+ try {
186
+ // Parse tool input from Claude Code
187
+ const input = JSON.parse(inputData);
188
+ const value = getInputValue(input);
189
+
190
+ if (!value) {
191
+ // No value to validate - allow
192
+ process.exit(0);
193
+ }
194
+
195
+ // Load patterns and validate
196
+ const config = loadConfig();
197
+ const result = validate(value, config);
198
+
199
+ switch (result.action) {
200
+ case 'block':
201
+ onBlock(result, value);
202
+ process.exit(2);
203
+ break;
204
+
205
+ case 'ask':
206
+ if (onAsk) {
207
+ onAsk(result, value);
208
+ } else {
209
+ // Default ask behavior - output JSON
210
+ console.log(
211
+ JSON.stringify({
212
+ result: 'ask',
213
+ message: result.reason,
214
+ })
215
+ );
216
+ }
217
+ process.exit(0);
218
+ break;
219
+
220
+ case 'allow':
221
+ default:
222
+ process.exit(0);
223
+ }
224
+ } catch (e) {
225
+ // Parse error or other issue - fail open
226
+ process.exit(0);
227
+ }
228
+ });
229
+
230
+ // Handle no stdin (direct invocation)
231
+ process.stdin.on('error', () => {
232
+ process.exit(0);
233
+ });
234
+
235
+ // Set timeout to prevent hanging
236
+ setTimeout(() => {
237
+ process.exit(0);
238
+ }, STDIN_TIMEOUT_MS);
239
+ }
240
+
241
+ module.exports = {
242
+ c,
243
+ findProjectRoot,
244
+ expandPath,
245
+ loadPatterns,
246
+ pathMatches,
247
+ outputBlocked,
248
+ runDamageControlHook,
249
+ CONFIG_PATHS,
250
+ STDIN_TIMEOUT_MS,
251
+ };
@@ -19,12 +19,13 @@ const fs = require('fs');
19
19
  const path = require('path');
20
20
  const { execSync } = require('child_process');
21
21
  const { c: C, box } = require('../lib/colors');
22
+ const { isValidCommandName } = require('../lib/validate');
22
23
 
23
24
  const DISPLAY_LIMIT = 30000; // Claude Code's Bash tool display limit
24
25
 
25
26
  // Optional: Register command for PreCompact context preservation
26
27
  const commandName = process.argv[2];
27
- if (commandName) {
28
+ if (commandName && isValidCommandName(commandName)) {
28
29
  const sessionStatePath = 'docs/09-agents/session-state.json';
29
30
  if (fs.existsSync(sessionStatePath)) {
30
31
  try {
@@ -378,7 +379,61 @@ function generateFullContent() {
378
379
  content += `${C.dim}No session-state.json found${C.reset}\n`;
379
380
  }
380
381
 
381
- // 4. INTERACTION MODE (AskUserQuestion guidance)
382
+ // 4. SESSION CONTEXT (multi-session awareness)
383
+ content += `\n${C.skyBlue}${C.bold}═══ Session Context ═══${C.reset}\n`;
384
+ const sessionManagerPath = path.join(__dirname, 'session-manager.js');
385
+ const altSessionManagerPath = '.agileflow/scripts/session-manager.js';
386
+
387
+ if (fs.existsSync(sessionManagerPath) || fs.existsSync(altSessionManagerPath)) {
388
+ const managerPath = fs.existsSync(sessionManagerPath)
389
+ ? sessionManagerPath
390
+ : altSessionManagerPath;
391
+ const sessionStatus = safeExec(`node "${managerPath}" status`);
392
+
393
+ if (sessionStatus) {
394
+ try {
395
+ const statusData = JSON.parse(sessionStatus);
396
+ if (statusData.current) {
397
+ const session = statusData.current;
398
+ const isMain = session.is_main === true;
399
+
400
+ if (isMain) {
401
+ content += `Session: ${C.mintGreen}Main project${C.reset} (Session ${session.id || 1})\n`;
402
+ } else {
403
+ // NON-MAIN SESSION - Show prominent banner
404
+ const sessionName = session.nickname
405
+ ? `${session.id} "${session.nickname}"`
406
+ : `${session.id}`;
407
+ content += `${C.teal}${C.bold}🔀 SESSION ${sessionName} (worktree)${C.reset}\n`;
408
+ content += `Branch: ${C.skyBlue}${session.branch || 'unknown'}${C.reset}\n`;
409
+ content += `Path: ${C.dim}${session.path || process.cwd()}${C.reset}\n`;
410
+
411
+ // Calculate relative path to main
412
+ const mainPath = process.cwd().replace(/-[^/]+$/, ''); // Heuristic: strip session suffix
413
+ content += `Main project: ${C.dim}${mainPath}${C.reset}\n`;
414
+
415
+ // Remind about merge flow
416
+ content += `${C.lavender}💡 When done: /agileflow:session:end → merge to main${C.reset}\n`;
417
+ }
418
+
419
+ // Show other active sessions
420
+ if (statusData.otherActive > 0) {
421
+ content += `${C.peach}⚠️ ${statusData.otherActive} other session(s) active${C.reset}\n`;
422
+ }
423
+ } else {
424
+ content += `${C.dim}No session registered${C.reset}\n`;
425
+ }
426
+ } catch (e) {
427
+ content += `${C.dim}Session manager available but status parse failed${C.reset}\n`;
428
+ }
429
+ } else {
430
+ content += `${C.dim}Session manager available${C.reset}\n`;
431
+ }
432
+ } else {
433
+ content += `${C.dim}Multi-session not configured${C.reset}\n`;
434
+ }
435
+
436
+ // 5. INTERACTION MODE (AskUserQuestion guidance)
382
437
  const metadata = safeReadJSON('docs/00-meta/agileflow-metadata.json');
383
438
  const askUserQuestionConfig = metadata?.features?.askUserQuestion;
384
439