vibe-forge 0.8.1 → 0.8.3

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 (51) hide show
  1. package/.claude/commands/configure-vcs.md +102 -102
  2. package/.claude/commands/forge.md +218 -218
  3. package/.claude/hooks/worker-loop.js +220 -217
  4. package/.claude/settings.json +89 -89
  5. package/README.md +149 -191
  6. package/agents/aegis/personality.md +303 -303
  7. package/agents/anvil/personality.md +278 -278
  8. package/agents/architect/personality.md +260 -260
  9. package/agents/crucible/personality.md +362 -362
  10. package/agents/crucible-x/personality.md +210 -210
  11. package/agents/ember/personality.md +293 -293
  12. package/agents/flux/personality.md +248 -248
  13. package/agents/furnace/personality.md +342 -342
  14. package/agents/herald/personality.md +249 -249
  15. package/agents/oracle/personality.md +284 -284
  16. package/agents/pixel/personality.md +140 -140
  17. package/agents/planning-hub/personality.md +473 -473
  18. package/agents/scribe/personality.md +253 -253
  19. package/agents/slag/personality.md +268 -268
  20. package/agents/temper/personality.md +270 -270
  21. package/bin/cli.js +372 -372
  22. package/bin/forge-daemon.sh +477 -477
  23. package/bin/forge-setup.sh +662 -661
  24. package/bin/forge-spawn.sh +164 -164
  25. package/bin/forge.sh +566 -566
  26. package/docs/commands.md +8 -8
  27. package/package.json +77 -77
  28. package/{bin → src}/lib/agents.sh +177 -177
  29. package/{bin → src}/lib/check-aliases.js +50 -50
  30. package/{bin → src}/lib/colors.sh +45 -44
  31. package/{bin → src}/lib/config.sh +347 -347
  32. package/{bin → src}/lib/constants.sh +241 -241
  33. package/{bin → src}/lib/daemon/budgets.sh +107 -107
  34. package/{bin → src}/lib/daemon/dependencies.sh +146 -146
  35. package/{bin → src}/lib/daemon/display.sh +128 -128
  36. package/{bin → src}/lib/daemon/notifications.sh +273 -273
  37. package/{bin → src}/lib/daemon/routing.sh +93 -93
  38. package/{bin → src}/lib/daemon/state.sh +163 -163
  39. package/{bin → src}/lib/daemon/sync.sh +103 -103
  40. package/{bin → src}/lib/database.sh +357 -357
  41. package/{bin → src}/lib/frontmatter.js +106 -106
  42. package/{bin → src}/lib/heimdall-setup.js +113 -113
  43. package/{bin → src}/lib/heimdall.js +265 -265
  44. package/src/lib/index.sh +25 -0
  45. package/{bin → src}/lib/json.sh +264 -264
  46. package/{bin → src}/lib/terminal.js +452 -452
  47. package/{bin → src}/lib/util.sh +126 -126
  48. package/{bin → src}/lib/vcs.js +349 -349
  49. package/{context → templates}/project-context-template.md +122 -122
  50. package/config/task-template.md +0 -159
  51. package/config/templates/handoff-template.md +0 -40
@@ -1,273 +1,273 @@
1
- #!/usr/bin/env bash
2
- #
3
- # bin/lib/daemon/notifications.sh
4
- #
5
- # Daemon notification functions - pending task alerts, attention signals,
6
- # system toasts, and Heimdall escalation handling
7
- #
8
- # Dependencies: colors.sh, constants.sh
9
- # Globals required: FORGE_ROOT, NOTIFY_FILE, NOTIFIED_FILE, LOG_FILE,
10
- # TASKS_PENDING, TASKS_NEEDS_CHANGES, TASKS_ATTENTION
11
-
12
- # Prevent double-sourcing
13
- [[ -n "${_DAEMON_NOTIFICATIONS_LOADED:-}" ]] && return 0
14
- _DAEMON_NOTIFICATIONS_LOADED=1
15
-
16
- # Node.js frontmatter helper (RT-20260405-001 MEDIUM-5: replaces grep/cut YAML parsing)
17
- FRONTMATTER_JS="${FRONTMATTER_JS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/frontmatter.js}"
18
-
19
- # Parse a single frontmatter field from a markdown file.
20
- _notif_fm_field() {
21
- node "$FRONTMATTER_JS" "$1" "$2" 2>/dev/null | sed -n "s/^${2}=//p"
22
- }
23
-
24
- # SECURITY: Sanitize message for safe use in shell commands
25
- # Removes/escapes characters that could cause command injection
26
- sanitize_notification_message() {
27
- local msg="$1"
28
- # Remove null bytes
29
- msg="${msg//$'\0'/}"
30
- # Remove characters that could escape out of string contexts
31
- # PowerShell: $, `, ', ", (), {}
32
- # osascript: ', ", \, $
33
- # We allow: alphanumeric, spaces, periods, commas, colons, hyphens, underscores
34
- msg=$(echo "$msg" | tr -cd 'a-zA-Z0-9 .,;:!?_-')
35
- # Limit length to prevent buffer issues
36
- msg="${msg:0:200}"
37
- echo "$msg"
38
- }
39
-
40
- notify() {
41
- local message="$1"
42
- local urgency="${2:-normal}" # normal or urgent
43
- local timestamp
44
- timestamp=$(date -Iseconds)
45
-
46
- # SECURITY: Sanitize before logging to prevent ANSI escape injection and
47
- # control-character log poisoning. sanitize_notification_message() strips
48
- # everything except alphanumeric, spaces, and safe punctuation.
49
- local safe_message
50
- safe_message=$(sanitize_notification_message "$message")
51
-
52
- # Log to notifications file
53
- echo "[$timestamp] $safe_message" >> "$NOTIFY_FILE"
54
-
55
- # Log to main log
56
- echo "[$timestamp] NOTIFY: $safe_message" >> "$LOG_FILE"
57
-
58
- # Terminal bell (works in most terminals)
59
- printf '\a'
60
-
61
- # System toast notification for urgent messages
62
- if [[ "$urgency" == "urgent" ]]; then
63
- send_system_notification "$safe_message"
64
- fi
65
- }
66
-
67
- # Send system-level notification (platform-specific)
68
- send_system_notification() {
69
- local message="$1"
70
- local title="Vibe Forge"
71
-
72
- # SECURITY: Sanitize message to prevent command injection
73
- message=$(sanitize_notification_message "$message")
74
-
75
- case "$(uname -s)" in
76
- MINGW*|MSYS*|CYGWIN*)
77
- # Windows: Use PowerShell toast notification
78
- # SECURITY: Message is sanitized above, title is hardcoded
79
- powershell.exe -NoProfile -Command "
80
- \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
81
- \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
82
- \$textNodes = \$template.GetElementsByTagName('text')
83
- \$textNodes.Item(0).AppendChild(\$template.CreateTextNode('$title')) | Out-Null
84
- \$textNodes.Item(1).AppendChild(\$template.CreateTextNode('$message')) | Out-Null
85
- \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$template)
86
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Vibe Forge').Show(\$toast)
87
- " 2>/dev/null &
88
- ;;
89
- Darwin)
90
- # macOS: Use osascript
91
- # SECURITY: Message is sanitized above
92
- osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null &
93
- ;;
94
- Linux)
95
- # Linux: Use notify-send if available
96
- # SECURITY: notify-send handles escaping, but message is sanitized anyway
97
- if command -v notify-send &>/dev/null; then
98
- notify-send "$title" "$message" 2>/dev/null &
99
- fi
100
- ;;
101
- esac
102
- }
103
-
104
- check_new_pending_tasks() {
105
- # Create notified file if it doesn't exist
106
- touch "$NOTIFIED_FILE"
107
-
108
- # Check for new pending tasks
109
- for task in "$FORGE_ROOT/$TASKS_PENDING"/*.md; do
110
- if [[ -f "$task" && ! -L "$task" ]]; then
111
- local filename
112
- filename=$(basename "$task")
113
-
114
- # Check if we've already notified about this task
115
- if ! grep -qF "$filename" "$NOTIFIED_FILE" 2>/dev/null; then
116
- # Extract task info from frontmatter safely
117
- local task_id task_title assigned_to
118
-
119
- task_id=$(_notif_fm_field "$task" "id")
120
- task_title=$(_notif_fm_field "$task" "title")
121
- assigned_to=$(_notif_fm_field "$task" "assigned_to")
122
-
123
- # Use filename as fallback
124
- task_id="${task_id:-$filename}"
125
- task_title="${task_title:-New task}"
126
-
127
- # Notify
128
- if [[ -n "$assigned_to" ]]; then
129
- notify "New task for $assigned_to: $task_title ($task_id)"
130
- else
131
- notify "New pending task: $task_title ($task_id)"
132
- fi
133
-
134
- # Mark as notified (atomic append)
135
- echo "$filename" >> "$NOTIFIED_FILE"
136
- fi
137
- fi
138
- done
139
-
140
- # Also check needs-changes for tasks that need rework
141
- for task in "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"/*.md; do
142
- if [[ -f "$task" && ! -L "$task" ]]; then
143
- local filename
144
- filename=$(basename "$task")
145
- local notified_key="needs-changes:$filename"
146
-
147
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
148
- local task_id assigned_to
149
- task_id=$(_notif_fm_field "$task" "id")
150
- assigned_to=$(_notif_fm_field "$task" "assigned_to")
151
-
152
- task_id="${task_id:-$filename}"
153
-
154
- if [[ -n "$assigned_to" ]]; then
155
- notify "Task needs changes ($assigned_to): $task_id"
156
- else
157
- notify "Task needs changes: $task_id"
158
- fi
159
-
160
- echo "$notified_key" >> "$NOTIFIED_FILE"
161
- fi
162
- fi
163
- done
164
- }
165
-
166
- check_attention_needed() {
167
- # Check for workers needing attention (urgent notifications)
168
- if [[ ! -d "$FORGE_ROOT/$TASKS_ATTENTION" ]]; then
169
- return 0
170
- fi
171
-
172
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
173
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
174
- local filename
175
- filename=$(basename "$attention_file")
176
- local notified_key="attention:$filename"
177
-
178
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
179
- # Extract attention info
180
- local agent issue
181
- agent=$(_notif_fm_field "$attention_file" "agent")
182
- issue=$(node "$FRONTMATTER_JS" --section "$attention_file" "Issue" 2>/dev/null)
183
- [[ -z "$issue" ]] && issue=$(grep -m1 "^##" "$attention_file" 2>/dev/null | sed 's/^## *//' | head -c 200)
184
-
185
- agent="${agent:-Unknown}"
186
- issue="${issue:-Needs attention}"
187
-
188
- # Ring bell multiple times for attention
189
- printf '\a\a\a'
190
-
191
- # Send urgent notification with toast
192
- notify "🔔 $agent needs help: $issue" "urgent"
193
-
194
- echo "$notified_key" >> "$NOTIFIED_FILE"
195
- fi
196
- fi
197
- done
198
- }
199
-
200
- # check_heimdall_escalations INBOX_DIR
201
- # Scans the lab worker inbox for .escalation signal files written by Heimdall
202
- # when a worker accumulates too many policy violations (Gjallarhorn sounded).
203
- # Writes an escalation handoff for lab sentinel to route to human review.
204
- check_heimdall_escalations() {
205
- local inbox_dir="$1"
206
-
207
- [[ -d "$inbox_dir" ]] || return 0
208
-
209
- # shopt -s globstar needed for **; fall back to find for compatibility
210
- local escalation_files
211
- mapfile -t escalation_files < <(find "$inbox_dir" -name "*.escalation" -type f 2>/dev/null)
212
-
213
- for escalation_file in "${escalation_files[@]}"; do
214
- [[ -f "$escalation_file" ]] || continue
215
-
216
- local story_id agent_dir agent handoff_dir
217
- story_id=$(basename "$escalation_file" .escalation)
218
- agent_dir=$(dirname "$escalation_file")
219
- agent=$(basename "$agent_dir")
220
-
221
- # Read escalation JSON for violation details
222
- local violations last_reason
223
- # SECURITY: Pass file path as argv, not string interpolation, to prevent
224
- # code injection via crafted filenames (RT-20260405-001 HIGH-1)
225
- violations=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.violations||'?')}catch(e){console.log('?')}" -- "$escalation_file" 2>/dev/null)
226
- last_reason=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.last_reason||'')}catch(e){console.log('')}" -- "$escalation_file" 2>/dev/null)
227
-
228
- echo "[$(date -Iseconds)] HEIMDALL SOUNDED: $agent/$story_id ($violations violations) -- writing escalation handoff" >> "$LOG_FILE"
229
-
230
- # Write escalation handoff for lab sentinel
231
- # Lab sentinel routes escalations to human review on next cycle
232
- local handoff_dir="$FORGE_ROOT/_vibe-chain-output/handoffs"
233
- if [[ -d "$handoff_dir" ]]; then
234
- local handoff_file="$handoff_dir/${story_id}-worker-escalation.md"
235
- cat > "$handoff_file" << HANDOFF
236
- ---
237
- type: worker-escalation
238
- story: "$story_id"
239
- from: forge-daemon
240
- to: human
241
- created: $(date -Iseconds)
242
- status: pending
243
- agent: "$agent"
244
- violations: $violations
245
- ---
246
-
247
- ## Heimdall Escalation
248
-
249
- Worker \`$agent\` accumulated $violations policy violations on story \`$story_id\`.
250
- The worker has been halted for this story. Human review required before resubmitting.
251
-
252
- ## Last Violation
253
-
254
- $last_reason
255
-
256
- ## Audit Log
257
-
258
- Full violation history: \`_vibe-chain-output/heimdall-audit.log\`
259
-
260
- ## Action Required
261
-
262
- Review the audit log and decide:
263
- - Fix the story spec and resubmit
264
- - Manually complete the work
265
- - Cancel the story
266
- HANDOFF
267
- echo "[$(date -Iseconds)] Escalation handoff written: $handoff_file" >> "$LOG_FILE"
268
- fi
269
-
270
- # Mark escalation as processed so it is not re-processed next cycle
271
- mv "$escalation_file" "${escalation_file}.processed" 2>/dev/null || true
272
- done
273
- }
1
+ #!/usr/bin/env bash
2
+ #
3
+ # src/lib/daemon/notifications.sh
4
+ #
5
+ # Daemon notification functions - pending task alerts, attention signals,
6
+ # system toasts, and Heimdall escalation handling
7
+ #
8
+ # Dependencies: colors.sh, constants.sh
9
+ # Globals required: FORGE_ROOT, NOTIFY_FILE, NOTIFIED_FILE, LOG_FILE,
10
+ # TASKS_PENDING, TASKS_NEEDS_CHANGES, TASKS_ATTENTION
11
+
12
+ # Prevent double-sourcing
13
+ [[ -n "${_DAEMON_NOTIFICATIONS_LOADED:-}" ]] && return 0
14
+ _DAEMON_NOTIFICATIONS_LOADED=1
15
+
16
+ # Node.js frontmatter helper (RT-20260405-001 MEDIUM-5: replaces grep/cut YAML parsing)
17
+ FRONTMATTER_JS="${FRONTMATTER_JS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/frontmatter.js}"
18
+
19
+ # Parse a single frontmatter field from a markdown file.
20
+ _notif_fm_field() {
21
+ node "$FRONTMATTER_JS" "$1" "$2" 2>/dev/null | sed -n "s/^${2}=//p"
22
+ }
23
+
24
+ # SECURITY: Sanitize message for safe use in shell commands
25
+ # Removes/escapes characters that could cause command injection
26
+ sanitize_notification_message() {
27
+ local msg="$1"
28
+ # Remove null bytes
29
+ msg="${msg//$'\0'/}"
30
+ # Remove characters that could escape out of string contexts
31
+ # PowerShell: $, `, ', ", (), {}
32
+ # osascript: ', ", \, $
33
+ # We allow: alphanumeric, spaces, periods, commas, colons, hyphens, underscores
34
+ msg=$(echo "$msg" | tr -cd 'a-zA-Z0-9 .,;:!?_-')
35
+ # Limit length to prevent buffer issues
36
+ msg="${msg:0:200}"
37
+ echo "$msg"
38
+ }
39
+
40
+ notify() {
41
+ local message="$1"
42
+ local urgency="${2:-normal}" # normal or urgent
43
+ local timestamp
44
+ timestamp=$(date -Iseconds)
45
+
46
+ # SECURITY: Sanitize before logging to prevent ANSI escape injection and
47
+ # control-character log poisoning. sanitize_notification_message() strips
48
+ # everything except alphanumeric, spaces, and safe punctuation.
49
+ local safe_message
50
+ safe_message=$(sanitize_notification_message "$message")
51
+
52
+ # Log to notifications file
53
+ echo "[$timestamp] $safe_message" >> "$NOTIFY_FILE"
54
+
55
+ # Log to main log
56
+ echo "[$timestamp] NOTIFY: $safe_message" >> "$LOG_FILE"
57
+
58
+ # Terminal bell (works in most terminals)
59
+ printf '\a'
60
+
61
+ # System toast notification for urgent messages
62
+ if [[ "$urgency" == "urgent" ]]; then
63
+ send_system_notification "$safe_message"
64
+ fi
65
+ }
66
+
67
+ # Send system-level notification (platform-specific)
68
+ send_system_notification() {
69
+ local message="$1"
70
+ local title="Vibe Forge"
71
+
72
+ # SECURITY: Sanitize message to prevent command injection
73
+ message=$(sanitize_notification_message "$message")
74
+
75
+ case "$(uname -s)" in
76
+ MINGW*|MSYS*|CYGWIN*)
77
+ # Windows: Use PowerShell toast notification
78
+ # SECURITY: Message is sanitized above, title is hardcoded
79
+ powershell.exe -NoProfile -Command "
80
+ \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
81
+ \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
82
+ \$textNodes = \$template.GetElementsByTagName('text')
83
+ \$textNodes.Item(0).AppendChild(\$template.CreateTextNode('$title')) | Out-Null
84
+ \$textNodes.Item(1).AppendChild(\$template.CreateTextNode('$message')) | Out-Null
85
+ \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$template)
86
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Vibe Forge').Show(\$toast)
87
+ " 2>/dev/null &
88
+ ;;
89
+ Darwin)
90
+ # macOS: Use osascript
91
+ # SECURITY: Message is sanitized above
92
+ osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null &
93
+ ;;
94
+ Linux)
95
+ # Linux: Use notify-send if available
96
+ # SECURITY: notify-send handles escaping, but message is sanitized anyway
97
+ if command -v notify-send &>/dev/null; then
98
+ notify-send "$title" "$message" 2>/dev/null &
99
+ fi
100
+ ;;
101
+ esac
102
+ }
103
+
104
+ check_new_pending_tasks() {
105
+ # Create notified file if it doesn't exist
106
+ touch "$NOTIFIED_FILE"
107
+
108
+ # Check for new pending tasks
109
+ for task in "$FORGE_ROOT/$TASKS_PENDING"/*.md; do
110
+ if [[ -f "$task" && ! -L "$task" ]]; then
111
+ local filename
112
+ filename=$(basename "$task")
113
+
114
+ # Check if we've already notified about this task
115
+ if ! grep -qF "$filename" "$NOTIFIED_FILE" 2>/dev/null; then
116
+ # Extract task info from frontmatter safely
117
+ local task_id task_title assigned_to
118
+
119
+ task_id=$(_notif_fm_field "$task" "id")
120
+ task_title=$(_notif_fm_field "$task" "title")
121
+ assigned_to=$(_notif_fm_field "$task" "assigned_to")
122
+
123
+ # Use filename as fallback
124
+ task_id="${task_id:-$filename}"
125
+ task_title="${task_title:-New task}"
126
+
127
+ # Notify
128
+ if [[ -n "$assigned_to" ]]; then
129
+ notify "New task for $assigned_to: $task_title ($task_id)"
130
+ else
131
+ notify "New pending task: $task_title ($task_id)"
132
+ fi
133
+
134
+ # Mark as notified (atomic append)
135
+ echo "$filename" >> "$NOTIFIED_FILE"
136
+ fi
137
+ fi
138
+ done
139
+
140
+ # Also check needs-changes for tasks that need rework
141
+ for task in "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"/*.md; do
142
+ if [[ -f "$task" && ! -L "$task" ]]; then
143
+ local filename
144
+ filename=$(basename "$task")
145
+ local notified_key="needs-changes:$filename"
146
+
147
+ if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
148
+ local task_id assigned_to
149
+ task_id=$(_notif_fm_field "$task" "id")
150
+ assigned_to=$(_notif_fm_field "$task" "assigned_to")
151
+
152
+ task_id="${task_id:-$filename}"
153
+
154
+ if [[ -n "$assigned_to" ]]; then
155
+ notify "Task needs changes ($assigned_to): $task_id"
156
+ else
157
+ notify "Task needs changes: $task_id"
158
+ fi
159
+
160
+ echo "$notified_key" >> "$NOTIFIED_FILE"
161
+ fi
162
+ fi
163
+ done
164
+ }
165
+
166
+ check_attention_needed() {
167
+ # Check for workers needing attention (urgent notifications)
168
+ if [[ ! -d "$FORGE_ROOT/$TASKS_ATTENTION" ]]; then
169
+ return 0
170
+ fi
171
+
172
+ for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
173
+ if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
174
+ local filename
175
+ filename=$(basename "$attention_file")
176
+ local notified_key="attention:$filename"
177
+
178
+ if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
179
+ # Extract attention info
180
+ local agent issue
181
+ agent=$(_notif_fm_field "$attention_file" "agent")
182
+ issue=$(node "$FRONTMATTER_JS" --section "$attention_file" "Issue" 2>/dev/null)
183
+ [[ -z "$issue" ]] && issue=$(grep -m1 "^##" "$attention_file" 2>/dev/null | sed 's/^## *//' | head -c 200)
184
+
185
+ agent="${agent:-Unknown}"
186
+ issue="${issue:-Needs attention}"
187
+
188
+ # Ring bell multiple times for attention
189
+ printf '\a\a\a'
190
+
191
+ # Send urgent notification with toast
192
+ notify "🔔 $agent needs help: $issue" "urgent"
193
+
194
+ echo "$notified_key" >> "$NOTIFIED_FILE"
195
+ fi
196
+ fi
197
+ done
198
+ }
199
+
200
+ # check_heimdall_escalations INBOX_DIR
201
+ # Scans the lab worker inbox for .escalation signal files written by Heimdall
202
+ # when a worker accumulates too many policy violations (Gjallarhorn sounded).
203
+ # Writes an escalation handoff for lab sentinel to route to human review.
204
+ check_heimdall_escalations() {
205
+ local inbox_dir="$1"
206
+
207
+ [[ -d "$inbox_dir" ]] || return 0
208
+
209
+ # shopt -s globstar needed for **; fall back to find for compatibility
210
+ local escalation_files
211
+ mapfile -t escalation_files < <(find "$inbox_dir" -name "*.escalation" -type f 2>/dev/null)
212
+
213
+ for escalation_file in "${escalation_files[@]}"; do
214
+ [[ -f "$escalation_file" ]] || continue
215
+
216
+ local story_id agent_dir agent handoff_dir
217
+ story_id=$(basename "$escalation_file" .escalation)
218
+ agent_dir=$(dirname "$escalation_file")
219
+ agent=$(basename "$agent_dir")
220
+
221
+ # Read escalation JSON for violation details
222
+ local violations last_reason
223
+ # SECURITY: Pass file path as argv, not string interpolation, to prevent
224
+ # code injection via crafted filenames (RT-20260405-001 HIGH-1)
225
+ violations=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.violations||'?')}catch(e){console.log('?')}" -- "$escalation_file" 2>/dev/null)
226
+ last_reason=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.last_reason||'')}catch(e){console.log('')}" -- "$escalation_file" 2>/dev/null)
227
+
228
+ echo "[$(date -Iseconds)] HEIMDALL SOUNDED: $agent/$story_id ($violations violations) -- writing escalation handoff" >> "$LOG_FILE"
229
+
230
+ # Write escalation handoff for lab sentinel
231
+ # Lab sentinel routes escalations to human review on next cycle
232
+ local handoff_dir="$FORGE_ROOT/_vibe-chain-output/handoffs"
233
+ if [[ -d "$handoff_dir" ]]; then
234
+ local handoff_file="$handoff_dir/${story_id}-worker-escalation.md"
235
+ cat > "$handoff_file" << HANDOFF
236
+ ---
237
+ type: worker-escalation
238
+ story: "$story_id"
239
+ from: forge-daemon
240
+ to: human
241
+ created: $(date -Iseconds)
242
+ status: pending
243
+ agent: "$agent"
244
+ violations: $violations
245
+ ---
246
+
247
+ ## Heimdall Escalation
248
+
249
+ Worker \`$agent\` accumulated $violations policy violations on story \`$story_id\`.
250
+ The worker has been halted for this story. Human review required before resubmitting.
251
+
252
+ ## Last Violation
253
+
254
+ $last_reason
255
+
256
+ ## Audit Log
257
+
258
+ Full violation history: \`_vibe-chain-output/heimdall-audit.log\`
259
+
260
+ ## Action Required
261
+
262
+ Review the audit log and decide:
263
+ - Fix the story spec and resubmit
264
+ - Manually complete the work
265
+ - Cancel the story
266
+ HANDOFF
267
+ echo "[$(date -Iseconds)] Escalation handoff written: $handoff_file" >> "$LOG_FILE"
268
+ fi
269
+
270
+ # Mark escalation as processed so it is not re-processed next cycle
271
+ mv "$escalation_file" "${escalation_file}.processed" 2>/dev/null || true
272
+ done
273
+ }