agileflow 2.94.1 → 2.95.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +3 -3
  3. package/lib/colors.generated.js +117 -0
  4. package/lib/colors.js +59 -109
  5. package/lib/generator-factory.js +333 -0
  6. package/lib/path-utils.js +49 -0
  7. package/lib/session-registry.js +25 -15
  8. package/lib/smart-json-file.js +40 -32
  9. package/lib/state-machine.js +286 -0
  10. package/package.json +1 -1
  11. package/scripts/agileflow-configure.js +7 -6
  12. package/scripts/archive-completed-stories.sh +86 -11
  13. package/scripts/babysit-context-restore.js +89 -0
  14. package/scripts/claude-tmux.sh +111 -5
  15. package/scripts/damage-control/bash-tool-damage-control.js +11 -247
  16. package/scripts/damage-control/edit-tool-damage-control.js +9 -249
  17. package/scripts/damage-control/write-tool-damage-control.js +9 -244
  18. package/scripts/generate-colors.js +314 -0
  19. package/scripts/lib/colors.generated.sh +82 -0
  20. package/scripts/lib/colors.sh +10 -70
  21. package/scripts/lib/configure-features.js +401 -0
  22. package/scripts/lib/context-loader.js +181 -52
  23. package/scripts/precompact-context.sh +54 -17
  24. package/scripts/session-coordinator.sh +2 -2
  25. package/scripts/session-manager.js +653 -10
  26. package/src/core/commands/audit.md +93 -0
  27. package/src/core/commands/auto.md +73 -0
  28. package/src/core/commands/babysit.md +169 -13
  29. package/src/core/commands/baseline.md +73 -0
  30. package/src/core/commands/batch.md +64 -0
  31. package/src/core/commands/blockers.md +60 -0
  32. package/src/core/commands/board.md +66 -0
  33. package/src/core/commands/choose.md +77 -0
  34. package/src/core/commands/ci.md +77 -0
  35. package/src/core/commands/compress.md +27 -1
  36. package/src/core/commands/configure.md +126 -10
  37. package/src/core/commands/council.md +74 -0
  38. package/src/core/commands/debt.md +72 -0
  39. package/src/core/commands/deploy.md +73 -0
  40. package/src/core/commands/deps.md +68 -0
  41. package/src/core/commands/docs.md +60 -0
  42. package/src/core/commands/feedback.md +68 -0
  43. package/src/core/commands/ideate.md +74 -0
  44. package/src/core/commands/impact.md +74 -0
  45. package/src/core/commands/install.md +529 -0
  46. package/src/core/commands/maintain.md +558 -0
  47. package/src/core/commands/metrics.md +75 -0
  48. package/src/core/commands/multi-expert.md +74 -0
  49. package/src/core/commands/packages.md +69 -0
  50. package/src/core/commands/readme-sync.md +64 -0
  51. package/src/core/commands/research/analyze.md +285 -121
  52. package/src/core/commands/research/import.md +281 -109
  53. package/src/core/commands/retro.md +76 -0
  54. package/src/core/commands/review.md +72 -0
  55. package/src/core/commands/rlm.md +83 -0
  56. package/src/core/commands/rpi.md +90 -0
  57. package/src/core/commands/session/cleanup.md +214 -12
  58. package/src/core/commands/session/end.md +155 -17
  59. package/src/core/commands/sprint.md +72 -0
  60. package/src/core/commands/story-validate.md +68 -0
  61. package/src/core/commands/template.md +69 -0
  62. package/src/core/commands/tests.md +83 -0
  63. package/src/core/commands/update.md +59 -0
  64. package/src/core/commands/validate-expertise.md +76 -0
  65. package/src/core/commands/velocity.md +74 -0
  66. package/src/core/commands/verify.md +91 -0
  67. package/src/core/commands/whats-new.md +69 -0
  68. package/src/core/commands/workflow.md +88 -0
  69. package/src/core/templates/command-documentation.md +187 -0
  70. package/tools/cli/commands/session.js +1171 -0
  71. package/tools/cli/commands/setup.js +2 -81
  72. package/tools/cli/installers/core/installer.js +0 -5
  73. package/tools/cli/installers/ide/claude-code.js +6 -0
  74. package/tools/cli/lib/config-manager.js +42 -5
@@ -105,13 +105,87 @@ echo -e "${BLUE}Cutoff date: $CUTOFF_DATE${NC}"
105
105
 
106
106
  # Archive using Node.js (more reliable for JSON manipulation)
107
107
  if command -v node &> /dev/null; then
108
- STATUS_FILE="$STATUS_FILE" ARCHIVE_DIR="$ARCHIVE_DIR" CUTOFF_DATE="$CUTOFF_DATE" node <<'EOF'
108
+ STATUS_FILE="$STATUS_FILE" ARCHIVE_DIR="$ARCHIVE_DIR" CUTOFF_DATE="$CUTOFF_DATE" PROJECT_ROOT="$PROJECT_ROOT" node <<'EOF'
109
109
  const fs = require('fs');
110
110
  const path = require('path');
111
111
 
112
112
  const statusFile = process.env.STATUS_FILE;
113
113
  const archiveDir = process.env.ARCHIVE_DIR;
114
114
  const cutoffDate = process.env.CUTOFF_DATE;
115
+ const projectRoot = process.env.PROJECT_ROOT;
116
+
117
+ // =============================================================================
118
+ // Security: Inline validatePath equivalent (US-0188)
119
+ // =============================================================================
120
+
121
+ /**
122
+ * Validate a path is safe and within the base directory.
123
+ * Rejects direct symlinks within the path but allows symlinked parent directories
124
+ * (needed for git worktrees where docs/ is often symlinked).
125
+ * @param {string} inputPath - Path to validate
126
+ * @param {string} baseDir - Allowed base directory
127
+ * @returns {{ ok: boolean, resolvedPath?: string, realPath?: string, error?: string }}
128
+ */
129
+ function validatePath(inputPath, baseDir) {
130
+ if (!inputPath || typeof inputPath !== 'string') {
131
+ return { ok: false, error: 'Path is required and must be a string' };
132
+ }
133
+ if (!baseDir || typeof baseDir !== 'string') {
134
+ return { ok: false, error: 'Base directory is required' };
135
+ }
136
+
137
+ // Resolve to absolute path
138
+ const resolvedPath = path.resolve(baseDir, inputPath);
139
+ const resolvedBase = path.resolve(baseDir);
140
+
141
+ // Check path stays within base directory (path traversal prevention)
142
+ if (!resolvedPath.startsWith(resolvedBase + path.sep) && resolvedPath !== resolvedBase) {
143
+ return { ok: false, error: `Path traversal detected: ${inputPath} escapes ${baseDir}` };
144
+ }
145
+
146
+ // Check if the final target path itself is a symlink (allowSymlinks: false for target)
147
+ // Note: We allow parent directories to be symlinks (needed for git worktrees)
148
+ try {
149
+ const stats = fs.lstatSync(resolvedPath);
150
+ if (stats.isSymbolicLink()) {
151
+ // The actual file/directory we're writing to is a symlink - reject
152
+ return { ok: false, error: `Target path is a symlink: ${resolvedPath}` };
153
+ }
154
+ } catch (e) {
155
+ // Path doesn't exist yet, that's OK for new files
156
+ if (e.code !== 'ENOENT') {
157
+ return { ok: false, error: `Cannot stat path: ${e.message}` };
158
+ }
159
+ }
160
+
161
+ // Use fs.realpathSync() to get the actual path after symlink resolution
162
+ let realPath = resolvedPath;
163
+ try {
164
+ realPath = fs.realpathSync(resolvedPath);
165
+ // We don't restrict realPath to baseDir because parent directories may be
166
+ // symlinked (e.g., git worktrees). The key protection is:
167
+ // 1. path.resolve() prevents ../../ traversal in the input
168
+ // 2. lstatSync() above prevents the target itself from being a symlink
169
+ } catch (e) {
170
+ // Path doesn't exist yet, use resolved path
171
+ if (e.code !== 'ENOENT') {
172
+ return { ok: false, error: `Cannot resolve real path: ${e.message}` };
173
+ }
174
+ realPath = resolvedPath;
175
+ }
176
+
177
+ return { ok: true, resolvedPath, realPath };
178
+ }
179
+
180
+ // =============================================================================
181
+ // Validate archive directory (US-0188)
182
+ // =============================================================================
183
+
184
+ const archiveDirValidation = validatePath(archiveDir, projectRoot);
185
+ if (!archiveDirValidation.ok) {
186
+ console.error(`\x1b[31mSecurity: ${archiveDirValidation.error}. Aborting.\x1b[0m`);
187
+ process.exit(1);
188
+ }
115
189
 
116
190
  // Read status.json
117
191
  const status = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
@@ -146,7 +220,7 @@ for (const [storyId, story] of Object.entries(toArchive)) {
146
220
  const date = new Date(story.completed_at);
147
221
  const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
148
222
 
149
- // Security: Validate monthKey matches expected format (YYYY-MM) to prevent path traversal
223
+ // Security: Validate monthKey matches expected format (YYYY-MM) to prevent path traversal (US-0188 AC)
150
224
  if (!/^\d{4}-\d{2}$/.test(monthKey)) {
151
225
  console.error(`\x1b[31mSkipping story ${storyId}: invalid date format\x1b[0m`);
152
226
  continue;
@@ -165,27 +239,28 @@ for (const [storyId, story] of Object.entries(toArchive)) {
165
239
 
166
240
  // Write archive files
167
241
  for (const [monthKey, archiveData] of Object.entries(byMonth)) {
168
- const archiveFile = path.join(archiveDir, `${monthKey}.json`);
242
+ const archiveFile = `${monthKey}.json`;
169
243
 
170
- // Security: Verify resolved path stays within archive directory
171
- const resolvedPath = path.resolve(archiveFile);
172
- const resolvedArchiveDir = path.resolve(archiveDir);
173
- if (!resolvedPath.startsWith(resolvedArchiveDir + path.sep) && resolvedPath !== resolvedArchiveDir) {
174
- console.error(`\x1b[31mSecurity: Archive path ${archiveFile} escapes archive directory. Skipping.\x1b[0m`);
244
+ // Security: Use validatePath() with allowSymlinks: false (US-0188 AC)
245
+ const validation = validatePath(archiveFile, archiveDir);
246
+ if (!validation.ok) {
247
+ console.error(`\x1b[31mSecurity: ${validation.error}. Skipping ${monthKey}.\x1b[0m`);
175
248
  continue;
176
249
  }
177
250
 
251
+ const finalPath = validation.resolvedPath;
252
+
178
253
  // Merge with existing archive if it exists
179
- if (fs.existsSync(archiveFile)) {
254
+ if (fs.existsSync(finalPath)) {
180
255
  try {
181
- const existing = JSON.parse(fs.readFileSync(archiveFile, 'utf8'));
256
+ const existing = JSON.parse(fs.readFileSync(finalPath, 'utf8'));
182
257
  archiveData.stories = { ...existing.stories, ...archiveData.stories };
183
258
  } catch (e) {
184
259
  console.error(`\x1b[31mWarning: Could not parse existing ${monthKey}.json, will overwrite\x1b[0m`);
185
260
  }
186
261
  }
187
262
 
188
- fs.writeFileSync(archiveFile, JSON.stringify(archiveData, null, 2));
263
+ fs.writeFileSync(finalPath, JSON.stringify(archiveData, null, 2));
189
264
  const count = Object.keys(archiveData.stories).length;
190
265
  console.log(`\x1b[32m✓ Archived ${count} stories to ${monthKey}.json\x1b[0m`);
191
266
  }
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * babysit-context-restore.js - UserPromptSubmit hook
4
+ *
5
+ * Backup mechanism to restore babysit context after plan mode clears context.
6
+ * When user selects "Clear context and bypass permissions" after ExitPlanMode,
7
+ * this hook fires on the next user prompt and reminds Claude of babysit rules.
8
+ *
9
+ * The primary mechanism is embedding rules in the plan file (Rule #6).
10
+ * This hook is a backup for edge cases where plan file approach might miss.
11
+ *
12
+ * Usage: Called automatically as UserPromptSubmit hook
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ // Find session-state.json - try multiple locations
19
+ function findSessionState() {
20
+ const locations = [
21
+ 'docs/09-agents/session-state.json',
22
+ path.join(process.env.CLAUDE_PROJECT_DIR || '.', 'docs/09-agents/session-state.json'),
23
+ ];
24
+
25
+ for (const loc of locations) {
26
+ if (fs.existsSync(loc)) {
27
+ return loc;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function main() {
34
+ const sessionStatePath = findSessionState();
35
+ if (!sessionStatePath) return;
36
+
37
+ try {
38
+ const state = JSON.parse(fs.readFileSync(sessionStatePath, 'utf8'));
39
+
40
+ // Check if restoration is pending
41
+ if (!state.babysit_pending_restore) return;
42
+
43
+ // Output restoration context
44
+ console.log('');
45
+ console.log('\x1b[36m╔══════════════════════════════════════════════════════════════╗\x1b[0m');
46
+ console.log(
47
+ '\x1b[36m║\x1b[0m \x1b[1m\x1b[33m/babysit CONTEXT RESTORED\x1b[0m \x1b[36m║\x1b[0m'
48
+ );
49
+ console.log('\x1b[36m╠══════════════════════════════════════════════════════════════╣\x1b[0m');
50
+ console.log(
51
+ '\x1b[36m║\x1b[0m /agileflow:babysit was active before context clear. \x1b[36m║\x1b[0m'
52
+ );
53
+ console.log(
54
+ '\x1b[36m║\x1b[0m These rules are MANDATORY: \x1b[36m║\x1b[0m'
55
+ );
56
+ console.log(
57
+ '\x1b[36m║\x1b[0m \x1b[36m║\x1b[0m'
58
+ );
59
+ console.log(
60
+ '\x1b[36m║\x1b[0m 1. ALWAYS end responses with AskUserQuestion tool \x1b[36m║\x1b[0m'
61
+ );
62
+ console.log(
63
+ '\x1b[36m║\x1b[0m 2. Use EnterPlanMode before non-trivial tasks \x1b[36m║\x1b[0m'
64
+ );
65
+ console.log(
66
+ '\x1b[36m║\x1b[0m 3. Delegate complex work to domain experts \x1b[36m║\x1b[0m'
67
+ );
68
+ console.log(
69
+ '\x1b[36m║\x1b[0m 4. Track progress with TodoWrite for 3+ step tasks \x1b[36m║\x1b[0m'
70
+ );
71
+ console.log(
72
+ '\x1b[36m║\x1b[0m \x1b[36m║\x1b[0m'
73
+ );
74
+ console.log(
75
+ '\x1b[36m║\x1b[0m For full context: /agileflow:babysit \x1b[36m║\x1b[0m'
76
+ );
77
+ console.log('\x1b[36m╚══════════════════════════════════════════════════════════════╝\x1b[0m');
78
+ console.log('');
79
+
80
+ // Clear the flag (one-time restoration)
81
+ state.babysit_pending_restore = false;
82
+ state.babysit_restored_at = new Date().toISOString();
83
+ fs.writeFileSync(sessionStatePath, JSON.stringify(state, null, 2) + '\n');
84
+ } catch (e) {
85
+ // Silently fail - don't break user's workflow
86
+ }
87
+ }
88
+
89
+ main();
@@ -5,28 +5,125 @@
5
5
  # ./claude-tmux.sh # Start in tmux with default session
6
6
  # ./claude-tmux.sh --no-tmux # Start without tmux (regular claude)
7
7
  # ./claude-tmux.sh -n # Same as --no-tmux
8
+ # ./claude-tmux.sh --rescue # Kill frozen session and restart fresh
9
+ # ./claude-tmux.sh --kill # Kill existing session completely
10
+ # ./claude-tmux.sh --help # Show help with keybinds
8
11
  #
9
12
  # When already in tmux: Just runs claude normally
10
13
  # When not in tmux: Creates a tmux session and runs claude inside it
14
+ #
15
+ # FREEZE RECOVERY:
16
+ # If Claude freezes inside tmux, use these keybinds:
17
+ # - Alt+k Send Ctrl+C twice (soft interrupt)
18
+ # - Alt+K Force kill the pane immediately
19
+ # - Alt+R Respawn pane with fresh shell
20
+ # - Alt+q Detach from tmux (session stays alive)
11
21
 
12
22
  set -e
13
23
 
14
- # Check for --no-tmux flag
24
+ # Parse arguments
15
25
  NO_TMUX=false
26
+ RESCUE=false
27
+ KILL_SESSION=false
28
+ SHOW_HELP=false
29
+
16
30
  for arg in "$@"; do
17
31
  case $arg in
18
32
  --no-tmux|-n)
19
33
  NO_TMUX=true
20
34
  shift
21
35
  ;;
36
+ --rescue|-r)
37
+ RESCUE=true
38
+ shift
39
+ ;;
40
+ --kill)
41
+ KILL_SESSION=true
42
+ shift
43
+ ;;
44
+ --help|-h)
45
+ SHOW_HELP=true
46
+ shift
47
+ ;;
22
48
  esac
23
49
  done
24
50
 
51
+ # Show help
52
+ if [ "$SHOW_HELP" = true ]; then
53
+ cat << 'EOF'
54
+ AgileFlow Claude tmux Wrapper
55
+
56
+ USAGE:
57
+ af [options] [claude-args...]
58
+ agileflow [options] [claude-args...]
59
+
60
+ OPTIONS:
61
+ --no-tmux, -n Run claude without tmux
62
+ --rescue, -r Kill frozen session and restart fresh
63
+ --kill Kill existing session completely
64
+ --help, -h Show this help
65
+
66
+ TMUX KEYBINDS:
67
+ Alt+1-9 Switch to window N
68
+ Alt+c Create new window
69
+ Alt+n/p Next/previous window
70
+ Alt+d Split horizontally
71
+ Alt+s Split vertically
72
+ Alt+arrows Navigate panes
73
+ Alt+z Zoom/unzoom pane
74
+ Alt+[ Enter copy mode (scroll)
75
+ Alt+r Rename window
76
+ Alt+x Close pane
77
+ Alt+w Close window
78
+ Alt+q Detach from tmux
79
+
80
+ FREEZE RECOVERY:
81
+ Alt+k Send Ctrl+C twice (soft interrupt)
82
+ Alt+K Force kill pane immediately
83
+ Alt+R Respawn pane with fresh shell
84
+
85
+ If Claude is completely frozen and keybinds don't work:
86
+ 1. Open a new terminal
87
+ 2. Run: af --rescue (kills and restarts)
88
+ 3. Or: af --kill (just kills, doesn't restart)
89
+ EOF
90
+ exit 0
91
+ fi
92
+
25
93
  # If --no-tmux was specified, just run claude directly
26
94
  if [ "$NO_TMUX" = true ]; then
27
95
  exec claude "$@"
28
96
  fi
29
97
 
98
+ # Generate session name based on current directory (needed for rescue/kill)
99
+ DIR_NAME=$(basename "$(pwd)")
100
+ SESSION_NAME="claude-${DIR_NAME}"
101
+
102
+ # Handle --kill flag
103
+ if [ "$KILL_SESSION" = true ]; then
104
+ if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
105
+ echo "Killing session: $SESSION_NAME"
106
+ tmux kill-session -t "$SESSION_NAME"
107
+ echo "Session killed."
108
+ else
109
+ echo "No session named '$SESSION_NAME' found."
110
+ fi
111
+ exit 0
112
+ fi
113
+
114
+ # Handle --rescue flag (kill and restart)
115
+ if [ "$RESCUE" = true ]; then
116
+ if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
117
+ echo "Killing frozen session: $SESSION_NAME"
118
+ tmux kill-session -t "$SESSION_NAME"
119
+ echo "Session killed. Restarting..."
120
+ sleep 0.5
121
+ else
122
+ echo "No existing session to rescue. Starting fresh..."
123
+ fi
124
+ # Continue to create new session below
125
+ fi
126
+
30
127
  # Check if tmux auto-spawn is disabled in config
31
128
  METADATA_FILE="docs/00-meta/agileflow-metadata.json"
32
129
  if [ -f "$METADATA_FILE" ]; then
@@ -62,9 +159,7 @@ if ! command -v tmux &> /dev/null; then
62
159
  exec claude "$@"
63
160
  fi
64
161
 
65
- # Generate session name based on current directory
66
- DIR_NAME=$(basename "$(pwd)")
67
- SESSION_NAME="claude-${DIR_NAME}"
162
+ # SESSION_NAME already generated above (needed for --rescue and --kill)
68
163
 
69
164
  # Check if session already exists
70
165
  if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
@@ -102,7 +197,8 @@ tmux set-option -t "$SESSION_NAME" status 2
102
197
  tmux set-option -t "$SESSION_NAME" status-style "bg=#1a1b26,fg=#a9b1d6"
103
198
 
104
199
  # Line 0 (top): Session name (stripped of claude- prefix) + Keybinds + Git branch
105
- tmux set-option -t "$SESSION_NAME" status-format[0] "#[bg=#1a1b26] #[fg=#e8683a bold]#{s/claude-//:session_name} #[fg=#3b4261]· #[fg=#7aa2f7]󰘬 #(git branch --show-current 2>/dev/null || echo '-') #[align=right]#[fg=#7a7e8a]Alt+1-9 tabs Alt+x close Alt+q detach "
200
+ # Shows freeze recovery keys: Alt+k (soft kill), Alt+K (hard kill)
201
+ tmux set-option -t "$SESSION_NAME" status-format[0] "#[bg=#1a1b26] #[fg=#e8683a bold]#{s/claude-//:session_name} #[fg=#3b4261]· #[fg=#7aa2f7]󰘬 #(git branch --show-current 2>/dev/null || echo '-') #[align=right]#[fg=#7a7e8a]Alt+k freeze Alt+x close Alt+q detach "
106
202
 
107
203
  # Line 1 (bottom): Window tabs with smart truncation and brand color
108
204
  # - Active window: full name (max 15 chars), brand orange highlight
@@ -163,6 +259,16 @@ tmux bind-key -n M-z resize-pane -Z
163
259
  # Alt+[ to enter copy mode (for scrolling)
164
260
  tmux bind-key -n M-[ copy-mode
165
261
 
262
+ # ─── Freeze Recovery Keybindings ─────────────────────────────────────────────
263
+ # Alt+k to send Ctrl+C twice (soft interrupt for frozen processes)
264
+ tmux bind-key -n M-k run-shell "tmux send-keys C-c; sleep 0.5; tmux send-keys C-c"
265
+
266
+ # Alt+K (shift+k) to force-kill pane immediately (nuclear option for hard freezes)
267
+ tmux bind-key -n M-K kill-pane
268
+
269
+ # Alt+R (shift+r) to respawn the pane (restart with a fresh shell)
270
+ tmux bind-key -n M-R respawn-pane -k
271
+
166
272
  # Send the claude command to the first window
167
273
  CLAUDE_CMD="claude"
168
274
  if [ $# -gt 0 ]; then
@@ -1,258 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * bash-tool-damage-control.js - Validate bash commands against security patterns
4
+ * bash-tool-damage-control.js - PreToolUse hook for Bash tool
5
5
  *
6
- * This PreToolUse hook runs before every Bash tool execution.
7
- * It checks the command against patterns.yaml to block or ask for
8
- * confirmation on dangerous commands.
6
+ * Validates bash commands against patterns in damage-control-patterns.yaml
7
+ * before execution. Part of AgileFlow's damage control system.
9
8
  *
10
9
  * Exit codes:
11
- * 0 = Allow command to proceed (or ask with JSON output)
12
- * 2 = Block command
10
+ * 0 - Allow command (or ask via JSON output)
11
+ * 2 - Block command
13
12
  *
14
- * For ask confirmation, outputs JSON:
15
- * {"result": "ask", "message": "Reason for asking"}
13
+ * For "ask" response, output JSON to stdout:
14
+ * { "result": "ask", "message": "Confirm this action?" }
16
15
  *
17
- * Usage (as PreToolUse hook):
18
- * node .claude/hooks/damage-control/bash-tool-damage-control.js
19
- *
20
- * Environment:
21
- * CLAUDE_TOOL_INPUT - JSON string with tool input (contains "command")
22
- * CLAUDE_PROJECT_DIR - Project root directory
23
- */
24
-
25
- const fs = require('fs');
26
- const path = require('path');
27
-
28
- // ANSI colors for output
29
- const c = {
30
- reset: '\x1b[0m',
31
- bold: '\x1b[1m',
32
- red: '\x1b[31m',
33
- yellow: '\x1b[33m',
34
- cyan: '\x1b[36m',
35
- };
36
-
37
- // Exit codes
38
- const EXIT_ALLOW = 0;
39
- const EXIT_BLOCK = 2;
40
-
41
- /**
42
- * Load patterns from YAML file
43
- * Falls back to built-in patterns if YAML parsing fails
44
- */
45
- function loadPatterns(projectDir) {
46
- const locations = [
47
- path.join(projectDir, '.claude/hooks/damage-control/patterns.yaml'),
48
- path.join(projectDir, '.agileflow/hooks/damage-control/patterns.yaml'),
49
- path.join(projectDir, 'patterns.yaml'),
50
- ];
51
-
52
- for (const loc of locations) {
53
- if (fs.existsSync(loc)) {
54
- try {
55
- const content = fs.readFileSync(loc, 'utf8');
56
- // Simple YAML parsing for our specific structure
57
- return parseSimpleYaml(content);
58
- } catch (e) {
59
- console.error(`Warning: Could not parse ${loc}: ${e.message}`);
60
- }
61
- }
62
- }
63
-
64
- // Return built-in defaults if no file found
65
- return getDefaultPatterns();
66
- }
67
-
68
- /**
69
- * Simple YAML parser for patterns.yaml structure
70
- * Only handles the specific structure we use (arrays of objects with pattern/reason/ask)
71
- */
72
- function parseSimpleYaml(content) {
73
- const patterns = {
74
- bashToolPatterns: [],
75
- askPatterns: [],
76
- agileflowPatterns: [],
77
- };
78
-
79
- let currentSection = null;
80
- let currentItem = null;
81
-
82
- const lines = content.split('\n');
83
-
84
- for (const line of lines) {
85
- // Skip comments and empty lines
86
- if (line.trim().startsWith('#') || line.trim() === '') continue;
87
-
88
- // Check for section headers
89
- if (line.match(/^bashToolPatterns:/)) {
90
- currentSection = 'bashToolPatterns';
91
- continue;
92
- }
93
- if (line.match(/^askPatterns:/)) {
94
- currentSection = 'askPatterns';
95
- continue;
96
- }
97
- if (line.match(/^agileflowPatterns:/)) {
98
- currentSection = 'agileflowPatterns';
99
- continue;
100
- }
101
- if (line.match(/^(zeroAccessPaths|readOnlyPaths|noDeletePaths|config):/)) {
102
- currentSection = null; // Skip non-pattern sections
103
- continue;
104
- }
105
-
106
- // Parse pattern items
107
- if (currentSection && patterns[currentSection]) {
108
- const patternMatch = line.match(/^\s+-\s*pattern:\s*['"]?(.+?)['"]?\s*$/);
109
- if (patternMatch) {
110
- currentItem = { pattern: patternMatch[1] };
111
- patterns[currentSection].push(currentItem);
112
- continue;
113
- }
114
-
115
- const reasonMatch = line.match(/^\s+reason:\s*['"]?(.+?)['"]?\s*$/);
116
- if (reasonMatch && currentItem) {
117
- currentItem.reason = reasonMatch[1];
118
- continue;
119
- }
120
-
121
- const askMatch = line.match(/^\s+ask:\s*(true|false)\s*$/);
122
- if (askMatch && currentItem) {
123
- currentItem.ask = askMatch[1] === 'true';
124
- continue;
125
- }
126
- }
127
- }
128
-
129
- return patterns;
130
- }
131
-
132
- /**
133
- * Built-in default patterns (used if patterns.yaml not found)
134
- */
135
- function getDefaultPatterns() {
136
- return {
137
- bashToolPatterns: [
138
- { pattern: '\\brm\\s+-[rRf]', reason: 'rm with recursive or force flags' },
139
- { pattern: 'DROP\\s+(TABLE|DATABASE)', reason: 'DROP commands are destructive' },
140
- { pattern: 'DELETE\\s+FROM\\s+\\w+\\s*;', reason: 'DELETE without WHERE clause' },
141
- { pattern: 'TRUNCATE\\s+(TABLE\\s+)?\\w+', reason: 'TRUNCATE removes all data' },
142
- {
143
- pattern: 'git\\s+push\\s+.*--force',
144
- reason: 'Force push can overwrite history',
145
- ask: true,
146
- },
147
- { pattern: 'git\\s+reset\\s+--hard', reason: 'Hard reset discards changes', ask: true },
148
- ],
149
- askPatterns: [
150
- { pattern: 'DELETE\\s+FROM\\s+\\w+\\s+WHERE', reason: 'Confirm record deletion' },
151
- { pattern: 'npm\\s+publish', reason: 'Publishing to npm is permanent' },
152
- ],
153
- agileflowPatterns: [
154
- { pattern: 'rm.*\\.agileflow', reason: 'Deleting .agileflow breaks installation' },
155
- { pattern: 'rm.*\\.claude', reason: 'Deleting .claude breaks configuration' },
156
- ],
157
- };
158
- }
159
-
160
- /**
161
- * Check command against patterns
162
- * Returns: { blocked: boolean, ask: boolean, reason: string }
163
- */
164
- function checkCommand(command, patterns) {
165
- // Combine all pattern sources
166
- const allPatterns = [...(patterns.bashToolPatterns || []), ...(patterns.agileflowPatterns || [])];
167
-
168
- // Check block/ask patterns
169
- for (const p of allPatterns) {
170
- try {
171
- const regex = new RegExp(p.pattern, 'i');
172
- if (regex.test(command)) {
173
- if (p.ask) {
174
- return { blocked: false, ask: true, reason: p.reason };
175
- }
176
- return { blocked: true, ask: false, reason: p.reason };
177
- }
178
- } catch (e) {
179
- // Invalid regex, skip
180
- console.error(`Warning: Invalid regex pattern: ${p.pattern}`);
181
- }
182
- }
183
-
184
- // Check ask-only patterns
185
- for (const p of patterns.askPatterns || []) {
186
- try {
187
- const regex = new RegExp(p.pattern, 'i');
188
- if (regex.test(command)) {
189
- return { blocked: false, ask: true, reason: p.reason };
190
- }
191
- } catch (e) {
192
- // Invalid regex, skip
193
- }
194
- }
195
-
196
- return { blocked: false, ask: false, reason: null };
197
- }
198
-
199
- /**
200
- * Main entry point
16
+ * Usage: Configured as PreToolUse hook in .claude/settings.json
201
17
  */
202
- function main() {
203
- // Get tool input from environment
204
- const toolInput = process.env.CLAUDE_TOOL_INPUT;
205
- const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
206
-
207
- if (!toolInput) {
208
- // No input, allow by default
209
- process.exit(EXIT_ALLOW);
210
- }
211
-
212
- let input;
213
- try {
214
- input = JSON.parse(toolInput);
215
- } catch (e) {
216
- console.error('Error parsing CLAUDE_TOOL_INPUT:', e.message);
217
- process.exit(EXIT_ALLOW);
218
- }
219
-
220
- const command = input.command;
221
- if (!command) {
222
- process.exit(EXIT_ALLOW);
223
- }
224
-
225
- // Load patterns
226
- const patterns = loadPatterns(projectDir);
227
-
228
- // Check command
229
- const result = checkCommand(command, patterns);
230
-
231
- if (result.blocked) {
232
- // Block the command
233
- console.error(`${c.red}${c.bold}BLOCKED${c.reset}: ${result.reason}`);
234
- console.error(`${c.yellow}Command: ${command}${c.reset}`);
235
- console.error(`${c.cyan}This command was blocked by damage control.${c.reset}`);
236
- process.exit(EXIT_BLOCK);
237
- }
238
-
239
- if (result.ask) {
240
- // Ask for confirmation
241
- const response = {
242
- result: 'ask',
243
- message: `${result.reason}\n\nCommand: ${command}\n\nProceed with this command?`,
244
- };
245
- console.log(JSON.stringify(response));
246
- process.exit(EXIT_ALLOW);
247
- }
248
-
249
- // Allow the command
250
- process.exit(EXIT_ALLOW);
251
- }
252
18
 
253
- // Run if called directly
254
- if (require.main === module) {
255
- main();
256
- }
19
+ const { createBashHook } = require('../lib/damage-control-utils');
257
20
 
258
- module.exports = { checkCommand, loadPatterns, parseSimpleYaml };
21
+ // Run the hook using factory
22
+ createBashHook()();