vibe-forge 0.3.12 → 0.8.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 (85) hide show
  1. package/.claude/commands/clear-attention.md +63 -63
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +102 -0
  4. package/.claude/commands/forge.md +218 -171
  5. package/.claude/commands/need-help.md +77 -77
  6. package/.claude/commands/update-status.md +64 -64
  7. package/.claude/commands/worker-loop.md +106 -106
  8. package/.claude/hooks/worker-loop.js +217 -0
  9. package/.claude/scripts/setup-worker-loop.sh +45 -45
  10. package/.claude/settings.json +89 -0
  11. package/LICENSE +21 -21
  12. package/README.md +253 -230
  13. package/agents/aegis/personality.md +303 -269
  14. package/agents/anvil/personality.md +278 -211
  15. package/agents/architect/personality.md +260 -0
  16. package/agents/crucible/personality.md +362 -285
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +293 -245
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +342 -262
  21. package/agents/herald/personality.md +249 -247
  22. package/agents/loki/personality.md +108 -0
  23. package/agents/oracle/personality.md +284 -0
  24. package/agents/pixel/personality.md +140 -0
  25. package/agents/planning-hub/personality.md +473 -251
  26. package/agents/scribe/personality.md +253 -231
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/temper/personality.md +270 -0
  29. package/bin/cli.js +372 -325
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +507 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
  34. package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
  35. package/bin/dashboard/public/index.html +14 -0
  36. package/bin/dashboard/server.js +645 -0
  37. package/bin/forge-daemon.sh +477 -775
  38. package/bin/forge-setup.sh +661 -532
  39. package/bin/forge-spawn.sh +164 -159
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +566 -393
  42. package/bin/lib/agents.sh +177 -177
  43. package/bin/lib/check-aliases.js +50 -0
  44. package/bin/lib/colors.sh +44 -44
  45. package/bin/lib/config.sh +347 -271
  46. package/bin/lib/constants.sh +241 -171
  47. package/bin/lib/daemon/budgets.sh +107 -0
  48. package/bin/lib/daemon/dependencies.sh +146 -0
  49. package/bin/lib/daemon/display.sh +128 -0
  50. package/bin/lib/daemon/notifications.sh +273 -0
  51. package/bin/lib/daemon/routing.sh +93 -0
  52. package/bin/lib/daemon/state.sh +163 -0
  53. package/bin/lib/daemon/sync.sh +103 -0
  54. package/bin/lib/database.sh +357 -224
  55. package/bin/lib/frontmatter.js +106 -0
  56. package/bin/lib/heimdall-setup.js +113 -0
  57. package/bin/lib/heimdall.js +265 -0
  58. package/bin/lib/json.sh +264 -0
  59. package/bin/lib/terminal.js +452 -0
  60. package/bin/lib/util.sh +126 -0
  61. package/bin/lib/vcs.js +349 -0
  62. package/config/agent-manifest.yaml +237 -230
  63. package/config/agents.json +207 -85
  64. package/config/task-template.md +159 -87
  65. package/config/task-types.yaml +111 -106
  66. package/config/templates/handoff-template.md +40 -0
  67. package/context/agent-overrides/README.md +41 -0
  68. package/context/architecture.md +42 -0
  69. package/context/modern-conventions.md +129 -129
  70. package/context/project-context-template.md +122 -122
  71. package/docs/agents.md +473 -0
  72. package/docs/architecture.md +194 -0
  73. package/docs/commands.md +451 -0
  74. package/docs/security.md +195 -144
  75. package/package.json +77 -48
  76. package/.claude/hooks/worker-loop.sh +0 -141
  77. package/.claude/settings.local.json +0 -29
  78. package/agents/forge-master/capabilities.md +0 -144
  79. package/agents/forge-master/context-template.md +0 -128
  80. package/agents/forge-master/personality.md +0 -138
  81. package/agents/sentinel/personality.md +0 -194
  82. package/context/forge-state.yaml +0 -19
  83. package/docs/TODO.md +0 -176
  84. package/docs/npm-publishing.md +0 -95
  85. package/tasks/review/task-001.md +0 -78
@@ -1,775 +1,477 @@
1
- #!/usr/bin/env bash
2
- #
3
- # Vibe Forge - Background Daemon
4
- # Monitors task folders and routes files automatically
5
- #
6
- # Usage:
7
- # forge-daemon.sh start - Start the daemon
8
- # forge-daemon.sh stop - Stop the daemon
9
- # forge-daemon.sh status - Check daemon status
10
- #
11
-
12
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
- FORGE_ROOT="$(dirname "$SCRIPT_DIR")"
14
-
15
- # =============================================================================
16
- # Load Shared Libraries
17
- # =============================================================================
18
-
19
- # shellcheck source=lib/colors.sh
20
- source "$SCRIPT_DIR/lib/colors.sh"
21
- # shellcheck source=lib/constants.sh
22
- source "$SCRIPT_DIR/lib/constants.sh"
23
- # shellcheck source=lib/config.sh
24
- source "$SCRIPT_DIR/lib/config.sh"
25
- # shellcheck source=lib/database.sh
26
- source "$SCRIPT_DIR/lib/database.sh"
27
-
28
- # =============================================================================
29
- # Daemon Configuration
30
- # =============================================================================
31
-
32
- CONFIG_FILE="$FORGE_ROOT/.forge/config.json"
33
- FORGE_DB="$FORGE_ROOT/.forge/forge.db"
34
- PID_FILE="$FORGE_ROOT/.forge/daemon.pid"
35
- LOG_FILE="$FORGE_ROOT/.forge/daemon.log"
36
- NOTIFY_FILE="$FORGE_ROOT/.forge/notifications.log"
37
- NOTIFIED_FILE="$FORGE_ROOT/.forge/notified-tasks.txt"
38
- STATE_FILE="$FORGE_ROOT/$CONTEXT_DIR/forge-state.yaml"
39
- LOCK_FILE="$FORGE_ROOT/.forge/daemon.lock"
40
-
41
- # Log file rotation settings
42
- MAX_LOG_SIZE=1048576 # 1MB
43
- MAX_NOTIFY_ENTRIES=1000
44
-
45
- # Load terminal type from config (safe parsing)
46
- TERMINAL_TYPE="manual"
47
- if [[ -f "$CONFIG_FILE" ]]; then
48
- TERMINAL_TYPE=$(json_get_string "$CONFIG_FILE" "terminal_type") || TERMINAL_TYPE="manual"
49
- fi
50
-
51
- # =============================================================================
52
- # Utility Functions
53
- # =============================================================================
54
-
55
- # Rotate log file if it gets too large
56
- rotate_log_if_needed() {
57
- local log="$1"
58
- if [[ -f "$log" ]]; then
59
- local size
60
- size=$(stat -f%z "$log" 2>/dev/null || stat --format=%s "$log" 2>/dev/null || echo 0)
61
- if [[ "$size" -gt "$MAX_LOG_SIZE" ]]; then
62
- mv "$log" "${log}.old"
63
- touch "$log"
64
- fi
65
- fi
66
- }
67
-
68
- # Trim notification entries to prevent unbounded growth
69
- trim_notified_file() {
70
- if [[ -f "$NOTIFIED_FILE" ]]; then
71
- local count
72
- count=$(wc -l < "$NOTIFIED_FILE" 2>/dev/null || echo 0)
73
- if [[ "$count" -gt "$MAX_NOTIFY_ENTRIES" ]]; then
74
- # Keep last 500 entries
75
- tail -500 "$NOTIFIED_FILE" > "${NOTIFIED_FILE}.tmp"
76
- mv "${NOTIFIED_FILE}.tmp" "$NOTIFIED_FILE"
77
- fi
78
- fi
79
- }
80
-
81
- # Safe file move with symlink protection
82
- safe_move_task() {
83
- local src="$1"
84
- local dest_dir="$2"
85
-
86
- # SECURITY: Skip symlinks to prevent symlink attacks
87
- if [[ -L "$src" ]]; then
88
- echo "[$(date -Iseconds)] WARNING: Skipping symlink: $src" >> "$LOG_FILE"
89
- return 1
90
- fi
91
-
92
- # SECURITY: Verify source is a regular file
93
- if [[ ! -f "$src" ]]; then
94
- return 1
95
- fi
96
-
97
- # SECURITY: Verify destination is within FORGE_ROOT
98
- local real_dest
99
- real_dest=$(cd "$dest_dir" 2>/dev/null && pwd)
100
- local forge_root_real
101
- forge_root_real=$(cd "$FORGE_ROOT" 2>/dev/null && pwd)
102
-
103
- if [[ "$real_dest" != "$forge_root_real"/* ]]; then
104
- echo "[$(date -Iseconds)] ERROR: Destination outside FORGE_ROOT: $dest_dir" >> "$LOG_FILE"
105
- return 1
106
- fi
107
-
108
- local filename
109
- filename=$(basename "$src")
110
- mv "$src" "$dest_dir/$filename"
111
- }
112
-
113
- # =============================================================================
114
- # Notification Functions
115
- # =============================================================================
116
-
117
- notify() {
118
- local message="$1"
119
- local urgency="${2:-normal}" # normal or urgent
120
- local timestamp
121
- timestamp=$(date -Iseconds)
122
-
123
- # Log to notifications file
124
- echo "[$timestamp] $message" >> "$NOTIFY_FILE"
125
-
126
- # Log to main log
127
- echo "[$timestamp] NOTIFY: $message" >> "$LOG_FILE"
128
-
129
- # Terminal bell (works in most terminals)
130
- printf '\a'
131
-
132
- # System toast notification for urgent messages
133
- if [[ "$urgency" == "urgent" ]]; then
134
- send_system_notification "$message"
135
- fi
136
- }
137
-
138
- # Send system-level notification (platform-specific)
139
- send_system_notification() {
140
- local message="$1"
141
- local title="Vibe Forge"
142
-
143
- case "$(uname -s)" in
144
- MINGW*|MSYS*|CYGWIN*)
145
- # Windows: Use PowerShell toast notification
146
- powershell.exe -NoProfile -Command "
147
- \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
148
- \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
149
- \$textNodes = \$template.GetElementsByTagName('text')
150
- \$textNodes.Item(0).AppendChild(\$template.CreateTextNode('$title')) | Out-Null
151
- \$textNodes.Item(1).AppendChild(\$template.CreateTextNode('$message')) | Out-Null
152
- \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$template)
153
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Vibe Forge').Show(\$toast)
154
- " 2>/dev/null &
155
- ;;
156
- Darwin)
157
- # macOS: Use osascript
158
- osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null &
159
- ;;
160
- Linux)
161
- # Linux: Use notify-send if available
162
- if command -v notify-send &>/dev/null; then
163
- notify-send "$title" "$message" 2>/dev/null &
164
- fi
165
- ;;
166
- esac
167
- }
168
-
169
- check_new_pending_tasks() {
170
- # Create notified file if it doesn't exist
171
- touch "$NOTIFIED_FILE"
172
-
173
- # Check for new pending tasks
174
- for task in "$FORGE_ROOT/$TASKS_PENDING"/*.md; do
175
- if [[ -f "$task" && ! -L "$task" ]]; then
176
- local filename
177
- filename=$(basename "$task")
178
-
179
- # Check if we've already notified about this task
180
- if ! grep -qF "$filename" "$NOTIFIED_FILE" 2>/dev/null; then
181
- # Extract task info from frontmatter safely
182
- local task_id task_title assigned_to
183
-
184
- # Use head to limit read, tr to sanitize, and strip ANSI escape sequences
185
- task_id=$(grep -m1 "^id:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | tr -d '\033' | sed 's/\[[0-9;]*m//g' | head -c 100)
186
- task_title=$(grep -m1 "^title:" "$task" 2>/dev/null | cut -d':' -f2- | tr -d '"' | tr -d '\033' | sed 's/\[[0-9;]*m//g' | head -c 200)
187
- assigned_to=$(grep -m1 "^assigned_to:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | tr -d '\033' | sed 's/\[[0-9;]*m//g' | head -c 50)
188
-
189
- # Use filename as fallback
190
- task_id="${task_id:-$filename}"
191
- task_title="${task_title:-New task}"
192
-
193
- # Notify
194
- if [[ -n "$assigned_to" ]]; then
195
- notify "New task for $assigned_to: $task_title ($task_id)"
196
- else
197
- notify "New pending task: $task_title ($task_id)"
198
- fi
199
-
200
- # Mark as notified (atomic append)
201
- echo "$filename" >> "$NOTIFIED_FILE"
202
- fi
203
- fi
204
- done
205
-
206
- # Also check needs-changes for tasks that need rework
207
- for task in "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"/*.md; do
208
- if [[ -f "$task" && ! -L "$task" ]]; then
209
- local filename
210
- filename=$(basename "$task")
211
- local notified_key="needs-changes:$filename"
212
-
213
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
214
- local task_id assigned_to
215
- task_id=$(grep -m1 "^id:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 100)
216
- assigned_to=$(grep -m1 "^assigned_to:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
217
-
218
- task_id="${task_id:-$filename}"
219
-
220
- if [[ -n "$assigned_to" ]]; then
221
- notify "Task needs changes ($assigned_to): $task_id"
222
- else
223
- notify "Task needs changes: $task_id"
224
- fi
225
-
226
- echo "$notified_key" >> "$NOTIFIED_FILE"
227
- fi
228
- fi
229
- done
230
- }
231
-
232
- check_attention_needed() {
233
- # Check for workers needing attention (urgent notifications)
234
- if [[ ! -d "$FORGE_ROOT/$TASKS_ATTENTION" ]]; then
235
- return 0
236
- fi
237
-
238
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
239
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
240
- local filename
241
- filename=$(basename "$attention_file")
242
- local notified_key="attention:$filename"
243
-
244
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
245
- # Extract attention info
246
- local agent issue
247
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
248
- issue=$(grep -m1 "^##" "$attention_file" 2>/dev/null | sed 's/^## *//' | head -c 200)
249
-
250
- agent="${agent:-Unknown}"
251
- issue="${issue:-Needs attention}"
252
-
253
- # Ring bell multiple times for attention
254
- printf '\a\a\a'
255
-
256
- # Send urgent notification with toast
257
- notify "🔔 $agent needs help: $issue" "urgent"
258
-
259
- echo "$notified_key" >> "$NOTIFIED_FILE"
260
- fi
261
- fi
262
- done
263
- }
264
-
265
- # Sync agent status from JSON files to SQLite (with mtime filtering)
266
- sync_agent_status_to_db() {
267
- local status_dir="$FORGE_ROOT/$AGENT_STATUS_DIR"
268
-
269
- if [[ ! -d "$status_dir" ]]; then
270
- return 0
271
- fi
272
-
273
- for status_file in "$status_dir"/*.json; do
274
- if [[ -f "$status_file" && ! -L "$status_file" ]]; then
275
- # Get file modification time
276
- local file_mtime
277
- file_mtime=$(stat -c %Y "$status_file" 2>/dev/null || stat -f %m "$status_file" 2>/dev/null || echo "0")
278
-
279
- # Get agent name from filename
280
- local agent_name
281
- agent_name=$(basename "$status_file" .json)
282
-
283
- # Check if file has changed since last read
284
- local stored_mtime
285
- stored_mtime=$(db_get_agent_mtime "$agent_name")
286
-
287
- if [[ "$file_mtime" -gt "$stored_mtime" ]]; then
288
- # File changed - parse and update DB
289
- local agent status task message updated
290
- agent=$(jq -r '.agent // "unknown"' "$status_file" 2>/dev/null)
291
- status=$(jq -r '.status // "unknown"' "$status_file" 2>/dev/null)
292
- task=$(jq -r '.task // ""' "$status_file" 2>/dev/null)
293
- message=$(jq -r '.message // ""' "$status_file" 2>/dev/null | head -c 80)
294
- updated=$(jq -r '.updated // ""' "$status_file" 2>/dev/null)
295
-
296
- # Upsert to database
297
- db_upsert_agent_status "$agent" "$status" "$task" "$message" "$updated" "$file_mtime"
298
-
299
- echo "[$(date -Iseconds)] Synced status for $agent: $status" >> "$LOG_FILE"
300
- fi
301
- fi
302
- done
303
- }
304
-
305
- # Build worker status from SQLite (for YAML output)
306
- build_worker_status() {
307
- local now_epoch
308
- now_epoch=$(date +%s)
309
- local stale_threshold=300 # 5 minutes
310
-
311
- # Check if we have any agent status in DB
312
- local agent_count
313
- agent_count=$(sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM agent_status;" 2>/dev/null || echo "0")
314
-
315
- if [[ "$agent_count" -eq 0 ]]; then
316
- return 0
317
- fi
318
-
319
- echo "workers:"
320
-
321
- # Read from database
322
- while IFS='|' read -r agent status task message updated; do
323
- local stale_marker=""
324
-
325
- # Check if stale
326
- if [[ -n "$updated" ]]; then
327
- local updated_epoch age
328
- updated_epoch=$(date -d "$updated" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${updated%Z}" +%s 2>/dev/null || echo "0")
329
- age=$((now_epoch - updated_epoch))
330
- if [[ "$age" -gt "$stale_threshold" ]]; then
331
- stale_marker=" (stale)"
332
- fi
333
- fi
334
-
335
- echo " - agent: $agent"
336
- echo " status: $status$stale_marker"
337
- if [[ -n "$task" ]]; then
338
- echo " task: $task"
339
- fi
340
- if [[ -n "$message" ]]; then
341
- echo " message: \"$message\""
342
- fi
343
- echo " updated: $updated"
344
- done < <(db_get_all_agent_statuses)
345
- }
346
-
347
- # =============================================================================
348
- # Daemon Functions
349
- # =============================================================================
350
-
351
- update_state() {
352
- # Count tasks in each folder (using find with -maxdepth for safety)
353
- local pending in_progress completed review approved needs_changes merged attention
354
- pending=$(find "$FORGE_ROOT/$TASKS_PENDING" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
355
- in_progress=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
356
- completed=$(find "$FORGE_ROOT/$TASKS_COMPLETED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
357
- review=$(find "$FORGE_ROOT/$TASKS_REVIEW" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
358
- approved=$(find "$FORGE_ROOT/$TASKS_APPROVED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
359
- needs_changes=$(find "$FORGE_ROOT/$TASKS_NEEDS_CHANGES" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
360
- merged=$(find "$FORGE_ROOT/$TASKS_MERGED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
361
- attention=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
362
-
363
- local blocked=0
364
-
365
- # Build attention details if any workers need help
366
- local attention_details=""
367
- if [[ "$attention" -gt 0 ]]; then
368
- attention_details=$(build_attention_details)
369
- fi
370
-
371
- # Build worker status from agent-status files
372
- local worker_status=""
373
- if [[ -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
374
- worker_status=$(build_worker_status)
375
- fi
376
-
377
- # Write state file atomically (write to temp, then move)
378
- local temp_state="${STATE_FILE}.tmp.$$"
379
- cat > "$temp_state" << EOF
380
- # Vibe Forge State
381
- # Auto-updated by forge-daemon
382
- # Last updated: $(date -Iseconds)
383
-
384
- forge:
385
- status: active
386
- daemon_pid: $$
387
-
388
- tasks:
389
- pending: $pending
390
- in_progress: $in_progress
391
- completed: $completed
392
- in_review: $review
393
- approved: $approved
394
- needs_changes: $needs_changes
395
- merged: $merged
396
- blocked: $blocked
397
- attention_needed: $attention
398
-
399
- $attention_details
400
- $worker_status
401
- last_updated: $(date -Iseconds)
402
- EOF
403
- mv "$temp_state" "$STATE_FILE"
404
- }
405
-
406
- build_attention_details() {
407
- echo "attention:"
408
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
409
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
410
- local agent created issue
411
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
412
- created=$(grep -m1 "^created:" "$attention_file" 2>/dev/null | cut -d':' -f2- | tr -d ' ' | head -c 30)
413
- # Get the issue line (first ## heading content or fallback)
414
- issue=$(sed -n '/^## Issue/,/^##/p' "$attention_file" 2>/dev/null | grep -v "^##" | head -1 | tr -d '\n' | head -c 100)
415
- issue="${issue:-Needs attention}"
416
-
417
- echo " - agent: $agent"
418
- echo " since: $created"
419
- echo " issue: \"$issue\""
420
- fi
421
- done
422
- }
423
-
424
- route_completed_to_review() {
425
- # Move completed tasks to review queue
426
- for task in "$FORGE_ROOT/$TASKS_COMPLETED"/*.md; do
427
- if [[ -f "$task" && ! -L "$task" ]]; then
428
- local filename
429
- filename=$(basename "$task")
430
- echo "[$(date -Iseconds)] Routing $filename to review" >> "$LOG_FILE"
431
- safe_move_task "$task" "$FORGE_ROOT/$TASKS_REVIEW"
432
- fi
433
- done
434
- }
435
-
436
- route_approved_to_merged() {
437
- # Move approved tasks to merged archive
438
- for task in "$FORGE_ROOT/$TASKS_APPROVED"/*.md; do
439
- if [[ -f "$task" && ! -L "$task" ]]; then
440
- local filename
441
- filename=$(basename "$task")
442
- echo "[$(date -Iseconds)] Archiving $filename to merged" >> "$LOG_FILE"
443
- safe_move_task "$task" "$FORGE_ROOT/$TASKS_MERGED"
444
- fi
445
- done
446
- }
447
-
448
- # Determine daemon state based on activity (for adaptive polling)
449
- determine_daemon_state() {
450
- # Check if there are in-progress tasks
451
- local in_progress_count
452
- in_progress_count=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
453
-
454
- # Check if there are active workers
455
- local active_workers
456
- active_workers=$(db_count_active_workers 2>/dev/null || echo "0")
457
-
458
- if [[ "$in_progress_count" -gt 0 ]] || [[ "$active_workers" -gt 0 ]]; then
459
- echo "active"
460
- else
461
- echo "idle"
462
- fi
463
- }
464
-
465
- # Get current poll interval in seconds (from DB, with fallback)
466
- get_poll_interval() {
467
- local interval_ms
468
- interval_ms=$(db_get_poll_interval_ms 2>/dev/null || echo "30000")
469
- # Convert ms to seconds (bash integer division)
470
- echo $((interval_ms / 1000))
471
- }
472
-
473
- daemon_loop() {
474
- echo "[$(date -Iseconds)] Forge daemon started (PID: $$)" >> "$LOG_FILE"
475
-
476
- # Create lock file
477
- echo $$ > "$LOCK_FILE"
478
-
479
- # Initialize database
480
- db_init
481
- echo "[$(date -Iseconds)] Database initialized at $FORGE_DB" >> "$LOG_FILE"
482
-
483
- # Cleanup on exit
484
- trap 'rm -f "$LOCK_FILE"; echo "[$(date -Iseconds)] Daemon exiting" >> "$LOG_FILE"' EXIT
485
-
486
- local iteration=0
487
- local current_state="idle"
488
- local poll_interval=2 # Start fast, adjust based on activity
489
-
490
- while true; do
491
- # Increment iteration counter
492
- ((iteration++)) || true
493
-
494
- # Sync agent status from JSON files to SQLite (with mtime filtering)
495
- sync_agent_status_to_db
496
-
497
- # Check for new tasks and notify
498
- check_new_pending_tasks
499
-
500
- # Check for workers needing attention (urgent)
501
- check_attention_needed
502
-
503
- # Route tasks
504
- route_completed_to_review
505
- route_approved_to_merged
506
-
507
- # Update state file
508
- update_state
509
-
510
- # Adaptive polling: check activity and adjust interval
511
- local new_state
512
- new_state=$(determine_daemon_state)
513
- if [[ "$new_state" != "$current_state" ]]; then
514
- current_state="$new_state"
515
- db_set_daemon_state "$current_state"
516
- poll_interval=$(get_poll_interval)
517
- echo "[$(date -Iseconds)] State changed to $current_state, poll interval: ${poll_interval}s" >> "$LOG_FILE"
518
- fi
519
-
520
- # Periodic maintenance (every 100 iterations)
521
- if [[ $((iteration % 100)) -eq 0 ]]; then
522
- rotate_log_if_needed "$LOG_FILE"
523
- rotate_log_if_needed "$NOTIFY_FILE"
524
- trim_notified_file
525
- # Cleanup stale agent status (30+ minutes old)
526
- db_cleanup_stale_agents 30
527
- # Prune history older than 7 days
528
- db_prune_history 7
529
- fi
530
-
531
- sleep "$poll_interval"
532
- done
533
- }
534
-
535
- # =============================================================================
536
- # Commands
537
- # =============================================================================
538
-
539
- cmd_start() {
540
- # Check if already running
541
- if [[ -f "$PID_FILE" ]]; then
542
- local pid
543
- pid=$(cat "$PID_FILE")
544
- if kill -0 "$pid" 2>/dev/null; then
545
- echo "Daemon already running (PID: $pid)"
546
- return 0
547
- else
548
- # Stale PID file
549
- rm -f "$PID_FILE"
550
- fi
551
- fi
552
-
553
- # Check for lock file (another instance check)
554
- if [[ -f "$LOCK_FILE" ]]; then
555
- local lock_pid
556
- lock_pid=$(cat "$LOCK_FILE" 2>/dev/null)
557
- if kill -0 "$lock_pid" 2>/dev/null; then
558
- echo "Another daemon instance is running (PID: $lock_pid)"
559
- return 1
560
- else
561
- rm -f "$LOCK_FILE"
562
- fi
563
- fi
564
-
565
- # Create directories if needed (with secure permissions)
566
- mkdir -p "$FORGE_ROOT/.forge"
567
- chmod 700 "$FORGE_ROOT/.forge"
568
-
569
- mkdir -p "$FORGE_ROOT/$TASKS_PENDING"
570
- mkdir -p "$FORGE_ROOT/$TASKS_IN_PROGRESS"
571
- mkdir -p "$FORGE_ROOT/$TASKS_COMPLETED"
572
- mkdir -p "$FORGE_ROOT/$TASKS_REVIEW"
573
- mkdir -p "$FORGE_ROOT/$TASKS_APPROVED"
574
- mkdir -p "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"
575
- mkdir -p "$FORGE_ROOT/$TASKS_MERGED"
576
- mkdir -p "$FORGE_ROOT/$TASKS_ATTENTION"
577
- mkdir -p "$FORGE_ROOT/$AGENT_STATUS_DIR"
578
-
579
- # Start daemon in background
580
- daemon_loop &
581
- local pid=$!
582
- echo "$pid" > "$PID_FILE"
583
-
584
- log_success "Forge daemon started (PID: $pid)"
585
- echo " Log: $LOG_FILE"
586
- }
587
-
588
- cmd_stop() {
589
- if [[ ! -f "$PID_FILE" ]]; then
590
- echo "Daemon not running"
591
- return 0
592
- fi
593
-
594
- local pid
595
- pid=$(cat "$PID_FILE")
596
-
597
- if kill -0 "$pid" 2>/dev/null; then
598
- kill "$pid"
599
- rm -f "$PID_FILE" "$LOCK_FILE"
600
- echo "[$(date -Iseconds)] Forge daemon stopped" >> "$LOG_FILE"
601
- log_success "Daemon stopped"
602
- else
603
- rm -f "$PID_FILE" "$LOCK_FILE"
604
- echo "Daemon was not running (stale PID file removed)"
605
- fi
606
-
607
- # Update state to show inactive
608
- if [[ -f "$STATE_FILE" ]]; then
609
- # Use sed carefully - different syntax on macOS vs Linux
610
- if [[ "$(uname)" == "Darwin" ]]; then
611
- sed -i '' 's/status: active/status: stopped/' "$STATE_FILE" 2>/dev/null || true
612
- else
613
- sed -i 's/status: active/status: stopped/' "$STATE_FILE" 2>/dev/null || true
614
- fi
615
- fi
616
- }
617
-
618
- cmd_status() {
619
- echo ""
620
- log_header "🔥 Forge Daemon Status"
621
-
622
- if [[ -f "$PID_FILE" ]]; then
623
- local pid
624
- pid=$(cat "$PID_FILE")
625
- if kill -0 "$pid" 2>/dev/null; then
626
- log_success "Running (PID: $pid)"
627
- else
628
- log_warn "Stopped (stale PID file)"
629
- fi
630
- else
631
- echo "Status: Stopped"
632
- fi
633
-
634
- echo ""
635
-
636
- if [[ -f "$STATE_FILE" ]]; then
637
- echo "Task Counts:"
638
- grep -E "pending:|in_progress:|completed:|in_review:|approved:|needs_changes:|merged:|attention_needed:" "$STATE_FILE" | sed 's/^/ /'
639
- fi
640
-
641
- echo ""
642
-
643
- # Show attention-needed workers prominently
644
- local attention_count
645
- attention_count=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
646
- if [[ "$attention_count" -gt 0 ]]; then
647
- echo -e "${RED}🔔 ATTENTION NEEDED:${NC}"
648
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
649
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
650
- local agent issue
651
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
652
- issue=$(sed -n '/^## Issue/,/^##/p' "$attention_file" 2>/dev/null | grep -v "^##" | head -1 | head -c 80)
653
- echo -e " ${YELLOW}$agent${NC}: $issue"
654
- fi
655
- done
656
- echo ""
657
- fi
658
-
659
- # Show worker status
660
- if [[ -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
661
- local status_count
662
- status_count=$(find "$FORGE_ROOT/$AGENT_STATUS_DIR" -maxdepth 1 -name "*.json" -type f 2>/dev/null | wc -l)
663
- if [[ "$status_count" -gt 0 ]]; then
664
- echo "Active Workers:"
665
- local now_epoch stale_threshold
666
- now_epoch=$(date +%s)
667
- stale_threshold=300
668
- for status_file in "$FORGE_ROOT/$AGENT_STATUS_DIR"/*.json; do
669
- if [[ -f "$status_file" && ! -L "$status_file" ]]; then
670
- local agent status task updated stale_marker icon
671
- agent=$(jq -r '.agent // "unknown"' "$status_file" 2>/dev/null)
672
- status=$(jq -r '.status // "unknown"' "$status_file" 2>/dev/null)
673
- task=$(jq -r '.task // ""' "$status_file" 2>/dev/null)
674
- updated=$(jq -r '.updated // ""' "$status_file" 2>/dev/null)
675
-
676
- # Check staleness
677
- stale_marker=""
678
- if [[ -n "$updated" ]]; then
679
- local updated_epoch age
680
- updated_epoch=$(date -d "$updated" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${updated%Z}" +%s 2>/dev/null || echo "0")
681
- age=$((now_epoch - updated_epoch))
682
- if [[ "$age" -gt "$stale_threshold" ]]; then
683
- stale_marker=" ${YELLOW}(stale)${NC}"
684
- fi
685
- fi
686
-
687
- # Status icon
688
- case "$status" in
689
- "working") icon="🔨" ;;
690
- "idle") icon="💤" ;;
691
- "blocked") icon="🚫" ;;
692
- "testing") icon="🧪" ;;
693
- "reviewing") icon="👁️" ;;
694
- *) icon="❓" ;;
695
- esac
696
-
697
- if [[ -n "$task" ]]; then
698
- echo -e " $icon ${CYAN}$agent${NC}: $status ($task)$stale_marker"
699
- else
700
- echo -e " $icon ${CYAN}$agent${NC}: $status$stale_marker"
701
- fi
702
- fi
703
- done
704
- echo ""
705
- fi
706
- fi
707
-
708
- # Show recent notifications
709
- if [[ -f "$NOTIFY_FILE" ]]; then
710
- local notify_count
711
- notify_count=$(wc -l < "$NOTIFY_FILE" 2>/dev/null || echo "0")
712
- if [[ "$notify_count" -gt 0 ]]; then
713
- echo "Recent Notifications (last 5):"
714
- tail -5 "$NOTIFY_FILE" | sed 's/^/ /'
715
- echo ""
716
- fi
717
- fi
718
-
719
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
720
- }
721
-
722
- cmd_notifications() {
723
- echo ""
724
- log_header "🔔 Forge Notifications"
725
-
726
- if [[ -f "$NOTIFY_FILE" ]]; then
727
- local count="${1:-10}"
728
- echo "Last $count notifications:"
729
- echo ""
730
- tail -"$count" "$NOTIFY_FILE"
731
- else
732
- echo "No notifications yet."
733
- fi
734
-
735
- echo ""
736
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
737
- }
738
-
739
- cmd_clear_notifications() {
740
- rm -f "$NOTIFY_FILE" "$NOTIFIED_FILE"
741
- log_success "Notifications cleared"
742
- }
743
-
744
- # =============================================================================
745
- # Main
746
- # =============================================================================
747
-
748
- main() {
749
- local command="${1:-status}"
750
-
751
- case "$command" in
752
- "start")
753
- cmd_start
754
- ;;
755
- "stop")
756
- cmd_stop
757
- ;;
758
- "status")
759
- cmd_status
760
- ;;
761
- "notifications"|"notify")
762
- shift
763
- cmd_notifications "$@"
764
- ;;
765
- "clear")
766
- cmd_clear_notifications
767
- ;;
768
- *)
769
- echo "Usage: forge-daemon.sh [start|stop|status|notifications|clear]"
770
- exit 1
771
- ;;
772
- esac
773
- }
774
-
775
- main "$@"
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Vibe Forge - Background Daemon
4
+ # Monitors task folders and routes files automatically
5
+ #
6
+ # Usage:
7
+ # forge-daemon.sh start - Start the daemon
8
+ # forge-daemon.sh stop - Stop the daemon
9
+ # forge-daemon.sh status - Check daemon status
10
+ #
11
+
12
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ FORGE_ROOT="$(dirname "$SCRIPT_DIR")"
14
+
15
+ # =============================================================================
16
+ # Load Shared Libraries
17
+ # =============================================================================
18
+
19
+ # shellcheck source=lib/colors.sh
20
+ source "$SCRIPT_DIR/lib/colors.sh"
21
+ # shellcheck source=lib/constants.sh
22
+ source "$SCRIPT_DIR/lib/constants.sh"
23
+ # shellcheck source=lib/config.sh
24
+ source "$SCRIPT_DIR/lib/config.sh"
25
+ # shellcheck source=lib/json.sh
26
+ source "$SCRIPT_DIR/lib/json.sh"
27
+ # shellcheck source=lib/database.sh
28
+ source "$SCRIPT_DIR/lib/database.sh"
29
+ # shellcheck source=lib/util.sh
30
+ source "$SCRIPT_DIR/lib/util.sh"
31
+
32
+ # Daemon modules (routing has no deps; sync before state)
33
+ # shellcheck source=lib/daemon/routing.sh
34
+ source "$SCRIPT_DIR/lib/daemon/routing.sh"
35
+ # shellcheck source=lib/daemon/notifications.sh
36
+ source "$SCRIPT_DIR/lib/daemon/notifications.sh"
37
+ # shellcheck source=lib/daemon/sync.sh
38
+ source "$SCRIPT_DIR/lib/daemon/sync.sh"
39
+ # shellcheck source=lib/daemon/state.sh
40
+ source "$SCRIPT_DIR/lib/daemon/state.sh"
41
+ # shellcheck source=lib/daemon/display.sh
42
+ source "$SCRIPT_DIR/lib/daemon/display.sh"
43
+ # shellcheck source=lib/daemon/budgets.sh
44
+ source "$SCRIPT_DIR/lib/daemon/budgets.sh"
45
+ # shellcheck source=lib/daemon/dependencies.sh
46
+ source "$SCRIPT_DIR/lib/daemon/dependencies.sh"
47
+
48
+ # =============================================================================
49
+ # Daemon Configuration
50
+ # =============================================================================
51
+
52
+ CONFIG_FILE="$FORGE_ROOT/.forge/config.json"
53
+ FORGE_DB="$FORGE_ROOT/.forge/forge.db"
54
+ PID_FILE="$FORGE_ROOT/.forge/daemon.pid"
55
+ LOG_FILE="$FORGE_ROOT/.forge/daemon.log"
56
+ NOTIFY_FILE="$FORGE_ROOT/.forge/notifications.log"
57
+ NOTIFIED_FILE="$FORGE_ROOT/.forge/notified-tasks.txt"
58
+ STATE_FILE="$FORGE_ROOT/$CONTEXT_DIR/forge-state.yaml"
59
+ LOCK_FILE="$FORGE_ROOT/.forge/daemon.lock"
60
+ DASHBOARD_PID_FILE="$FORGE_ROOT/.forge/dashboard.pid"
61
+
62
+ # Log file rotation settings (values defined in constants.sh)
63
+ # MAX_LOG_SIZE, MAX_NOTIFY_ENTRIES are loaded from constants.sh
64
+
65
+ # Load config (safe parsing via json_get_string)
66
+ TERMINAL_TYPE="manual"
67
+ DASHBOARD_ENABLED="false"
68
+ DASHBOARD_VOICE="false"
69
+ DASHBOARD_PORT="2800"
70
+ if [[ -f "$CONFIG_FILE" ]]; then
71
+ TERMINAL_TYPE=$(json_get_string "$CONFIG_FILE" "terminal_type") || TERMINAL_TYPE="manual"
72
+ DASHBOARD_ENABLED=$(json_get_string "$CONFIG_FILE" "dashboard_enabled") || DASHBOARD_ENABLED="false"
73
+ DASHBOARD_VOICE=$(json_get_string "$CONFIG_FILE" "dashboard_voice") || DASHBOARD_VOICE="false"
74
+ DASHBOARD_PORT=$(json_get_string "$CONFIG_FILE" "dashboard_port") || DASHBOARD_PORT="2800"
75
+ fi
76
+
77
+ # =============================================================================
78
+ # Utility Functions (daemon-local)
79
+ # =============================================================================
80
+
81
+ # Rotate log file if it gets too large
82
+ rotate_log_if_needed() {
83
+ local log="$1"
84
+ if [[ -f "$log" ]]; then
85
+ local size
86
+ size=$(stat -f%z "$log" 2>/dev/null || stat --format=%s "$log" 2>/dev/null || echo 0)
87
+ if [[ "$size" -gt "$MAX_LOG_SIZE" ]]; then
88
+ mv "$log" "${log}.old"
89
+ touch "$log"
90
+ fi
91
+ fi
92
+ }
93
+
94
+ # Trim notification entries to prevent unbounded growth
95
+ trim_notified_file() {
96
+ if [[ -f "$NOTIFIED_FILE" ]]; then
97
+ local count
98
+ count=$(wc -l < "$NOTIFIED_FILE" 2>/dev/null || echo 0)
99
+ if [[ "$count" -gt "$MAX_NOTIFY_ENTRIES" ]]; then
100
+ # Keep last 500 entries
101
+ tail -500 "$NOTIFIED_FILE" > "${NOTIFIED_FILE}.tmp"
102
+ mv "${NOTIFIED_FILE}.tmp" "$NOTIFIED_FILE"
103
+ fi
104
+ fi
105
+ }
106
+
107
+ # =============================================================================
108
+ # Daemon Loop
109
+ # =============================================================================
110
+
111
+ daemon_loop() {
112
+ echo "[$(date -Iseconds)] Forge daemon started (PID: $$)" >> "$LOG_FILE"
113
+
114
+ # Atomic lock file acquisition (noclobber = O_EXCL, prevents TOCTOU race on systems without flock)
115
+ if ! (set -o noclobber; echo $$ > "$LOCK_FILE") 2>/dev/null; then
116
+ echo "[$(date -Iseconds)] Lock file already held by another daemon instance — exiting" >> "$LOG_FILE"
117
+ exit 1
118
+ fi
119
+
120
+ # Initialize database (will attempt sqlite3 install if missing)
121
+ if ! db_init; then
122
+ echo "[$(date -Iseconds)] FATAL: Database initialization failed (sqlite3 missing?)" >> "$LOG_FILE"
123
+ rm -f "$LOCK_FILE"
124
+ exit 1
125
+ fi
126
+ echo "[$(date -Iseconds)] Database initialized at $FORGE_DB" >> "$LOG_FILE"
127
+
128
+ # Cleanup on exit
129
+ trap 'rm -f "$LOCK_FILE"; echo "[$(date -Iseconds)] Daemon exiting" >> "$LOG_FILE"' EXIT
130
+
131
+ local iteration=0
132
+ local current_state="idle"
133
+ local poll_interval=2 # Start fast, adjust based on activity
134
+
135
+ while true; do
136
+ # Increment iteration counter
137
+ ((iteration++)) || true
138
+
139
+ # Sync agent status from JSON files to SQLite (with mtime filtering)
140
+ sync_agent_status_to_db
141
+
142
+ # Check for new tasks and notify
143
+ check_new_pending_tasks
144
+
145
+ # Check for workers needing attention (urgent)
146
+ check_attention_needed
147
+
148
+ # Check for Heimdall escalations (lab worker policy violations)
149
+ check_heimdall_escalations "$FORGE_ROOT/_vibe-chain-output/worker-inbox"
150
+
151
+ # Check token budget warnings for long-running agents (T2-F1)
152
+ check_token_budgets
153
+
154
+ # Check task dependencies (T2-H2)
155
+ check_task_dependencies
156
+
157
+ # Route tasks
158
+ route_completed_to_review
159
+ route_approved_to_merged
160
+
161
+ # Update state file
162
+ update_state
163
+
164
+ # Adaptive polling: check activity and adjust interval
165
+ local new_state
166
+ new_state=$(determine_daemon_state)
167
+ if [[ "$new_state" != "$current_state" ]]; then
168
+ current_state="$new_state"
169
+ db_set_daemon_state "$current_state"
170
+ poll_interval=$(get_poll_interval)
171
+ echo "[$(date -Iseconds)] State changed to $current_state, poll interval: ${poll_interval}s" >> "$LOG_FILE"
172
+ fi
173
+
174
+ # Periodic maintenance (every MAINTENANCE_INTERVAL iterations)
175
+ if [[ $((iteration % MAINTENANCE_INTERVAL)) -eq 0 ]]; then
176
+ rotate_log_if_needed "$LOG_FILE"
177
+ rotate_log_if_needed "$NOTIFY_FILE"
178
+ trim_notified_file
179
+ # Cleanup stale agent status
180
+ db_cleanup_stale_agents "$STALE_CLEANUP_MINUTES"
181
+ # Prune old history
182
+ db_prune_history "$HISTORY_PRUNE_DAYS"
183
+ fi
184
+
185
+ sleep "$poll_interval"
186
+ done
187
+ }
188
+
189
+ # =============================================================================
190
+ # Watchdog
191
+ # =============================================================================
192
+
193
+ # WATCHDOG_MAX_RESTARTS -- how many times the watchdog will restart a crashed
194
+ # daemon_loop before giving up. Override via environment if needed.
195
+ WATCHDOG_MAX_RESTARTS="${WATCHDOG_MAX_RESTARTS:-5}"
196
+
197
+ # watchdog_loop
198
+ # Wraps daemon_loop with automatic restart on unexpected exit.
199
+ # Intentional stops (cmd_stop) are detected via the .stopping sentinel file,
200
+ # which cmd_stop creates before sending SIGTERM to the watchdog PID.
201
+ watchdog_loop() {
202
+ local restart_count=0
203
+ local backoff=5 # seconds; doubles each restart up to 60s
204
+
205
+ while [[ $restart_count -lt $WATCHDOG_MAX_RESTARTS ]]; do
206
+ daemon_loop
207
+ local exit_code=$?
208
+
209
+ # Intentional stop: cmd_stop touches .stopping before killing us
210
+ if [[ -f "$FORGE_ROOT/.forge/daemon.stopping" ]]; then
211
+ rm -f "$FORGE_ROOT/.forge/daemon.stopping"
212
+ echo "[$(date -Iseconds)] WATCHDOG: daemon stopped intentionally" >> "$LOG_FILE"
213
+ return 0
214
+ fi
215
+
216
+ restart_count=$((restart_count + 1))
217
+ echo "[$(date -Iseconds)] WATCHDOG: daemon exited unexpectedly (code $exit_code), restarting in ${backoff}s (attempt $restart_count/$WATCHDOG_MAX_RESTARTS)" >> "$LOG_FILE"
218
+
219
+ sleep "$backoff"
220
+ backoff=$((backoff * 2))
221
+ [[ $backoff -gt 60 ]] && backoff=60
222
+ done
223
+
224
+ echo "[$(date -Iseconds)] WATCHDOG: daemon exceeded $WATCHDOG_MAX_RESTARTS restart attempts — giving up" >> "$LOG_FILE"
225
+ }
226
+
227
+ # =============================================================================
228
+ # Dashboard Commands
229
+ # =============================================================================
230
+
231
+ cmd_dashboard_start() {
232
+ if ! command -v node &>/dev/null; then
233
+ log_warn "Node.js not found cannot start dashboard"
234
+ return 1
235
+ fi
236
+
237
+ # Check already running
238
+ if [[ -f "$DASHBOARD_PID_FILE" ]]; then
239
+ local pid
240
+ pid=$(cat "$DASHBOARD_PID_FILE")
241
+ if kill -0 "$pid" 2>/dev/null; then
242
+ echo "Dashboard already running (PID: $pid)"
243
+ echo " URL: http://localhost:${DASHBOARD_PORT}"
244
+ return 0
245
+ else
246
+ rm -f "$DASHBOARD_PID_FILE"
247
+ fi
248
+ fi
249
+
250
+ DASHBOARD_PORT="$DASHBOARD_PORT" node "$SCRIPT_DIR/dashboard/server.js" \
251
+ >> "$FORGE_ROOT/.forge/dashboard.log" 2>&1 &
252
+ local pid=$!
253
+ echo "$pid" > "$DASHBOARD_PID_FILE"
254
+
255
+ log_success "Dashboard started (PID: $pid)"
256
+ echo " URL: http://localhost:${DASHBOARD_PORT}"
257
+ echo " Log: $FORGE_ROOT/.forge/dashboard.log"
258
+
259
+ # Open browser (platform-specific, best-effort)
260
+ case "$(uname -s)" in
261
+ MINGW*|MSYS*|CYGWIN*)
262
+ cmd.exe /c start "" "http://localhost:${DASHBOARD_PORT}" 2>/dev/null & ;;
263
+ Darwin)
264
+ open "http://localhost:${DASHBOARD_PORT}" 2>/dev/null & ;;
265
+ Linux)
266
+ xdg-open "http://localhost:${DASHBOARD_PORT}" 2>/dev/null & ;;
267
+ esac
268
+ }
269
+
270
+ cmd_dashboard_stop() {
271
+ if [[ ! -f "$DASHBOARD_PID_FILE" ]]; then
272
+ echo "Dashboard not running"
273
+ return 0
274
+ fi
275
+
276
+ local pid
277
+ pid=$(cat "$DASHBOARD_PID_FILE")
278
+ if kill -0 "$pid" 2>/dev/null; then
279
+ kill "$pid"
280
+ rm -f "$DASHBOARD_PID_FILE"
281
+ log_success "Dashboard stopped"
282
+ else
283
+ rm -f "$DASHBOARD_PID_FILE"
284
+ echo "Dashboard was not running (stale PID file removed)"
285
+ fi
286
+ }
287
+
288
+ # =============================================================================
289
+ # Commands
290
+ # =============================================================================
291
+
292
+ cmd_start() {
293
+ # Create directories if needed (with secure permissions)
294
+ mkdir -p "$FORGE_ROOT/.forge"
295
+ chmod 700 "$FORGE_ROOT/.forge"
296
+
297
+ # SECURITY: Use flock for atomic lock acquisition to prevent TOCTOU race
298
+ # This prevents multiple daemon instances from starting simultaneously
299
+ local lock_fd=200
300
+ local startup_lock="$FORGE_ROOT/.forge/startup.lock"
301
+
302
+ # Try to acquire exclusive lock (non-blocking)
303
+ if command -v flock &>/dev/null; then
304
+ # flock available (Linux, some Git Bash installations)
305
+ exec 200>"$startup_lock"
306
+ if ! flock -n 200; then
307
+ echo "Another daemon startup is in progress"
308
+ return 1
309
+ fi
310
+ # Lock acquired - will be released when subshell exits or fd closes
311
+ fi
312
+ # If flock not available, fall back to PID-based check (less secure but functional)
313
+
314
+ # Check if already running
315
+ if [[ -f "$PID_FILE" ]]; then
316
+ local pid
317
+ pid=$(cat "$PID_FILE")
318
+ if kill -0 "$pid" 2>/dev/null; then
319
+ echo "Daemon already running (PID: $pid)"
320
+ return 0
321
+ else
322
+ # Stale PID file
323
+ rm -f "$PID_FILE"
324
+ fi
325
+ fi
326
+
327
+ # Check for lock file (another instance check - defense in depth)
328
+ if [[ -f "$LOCK_FILE" ]]; then
329
+ local lock_pid
330
+ lock_pid=$(cat "$LOCK_FILE" 2>/dev/null)
331
+ if kill -0 "$lock_pid" 2>/dev/null; then
332
+ echo "Another daemon instance is running (PID: $lock_pid)"
333
+ return 1
334
+ else
335
+ rm -f "$LOCK_FILE"
336
+ fi
337
+ fi
338
+
339
+ mkdir -p "$FORGE_ROOT/$TASKS_PENDING"
340
+ mkdir -p "$FORGE_ROOT/$TASKS_IN_PROGRESS"
341
+ mkdir -p "$FORGE_ROOT/$TASKS_COMPLETED"
342
+ mkdir -p "$FORGE_ROOT/$TASKS_REVIEW"
343
+ mkdir -p "$FORGE_ROOT/$TASKS_APPROVED"
344
+ mkdir -p "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"
345
+ mkdir -p "$FORGE_ROOT/$TASKS_MERGED"
346
+ mkdir -p "$FORGE_ROOT/$TASKS_ATTENTION"
347
+ mkdir -p "$FORGE_ROOT/$AGENT_STATUS_DIR"
348
+
349
+ # Start daemon under watchdog (auto-restarts on unexpected exit)
350
+ watchdog_loop &
351
+ local pid=$!
352
+ echo "$pid" > "$PID_FILE"
353
+
354
+ log_success "Forge daemon started (PID: $pid)"
355
+ echo " Log: $LOG_FILE"
356
+
357
+ # Auto-launch dashboard if voice or dashboard is enabled in config
358
+ if [[ "$DASHBOARD_VOICE" == "true" ]] || [[ "$DASHBOARD_ENABLED" == "true" ]]; then
359
+ cmd_dashboard_start
360
+ fi
361
+
362
+ # Note: flock is automatically released when the fd is closed (script exits)
363
+ }
364
+
365
+ cmd_stop() {
366
+ if [[ ! -f "$PID_FILE" ]]; then
367
+ echo "Daemon not running"
368
+ return 0
369
+ fi
370
+
371
+ local pid
372
+ pid=$(cat "$PID_FILE")
373
+
374
+ if kill -0 "$pid" 2>/dev/null; then
375
+ # Signal the watchdog that this is an intentional stop before killing it
376
+ touch "$FORGE_ROOT/.forge/daemon.stopping"
377
+ kill "$pid"
378
+ rm -f "$PID_FILE" "$LOCK_FILE"
379
+ echo "[$(date -Iseconds)] Forge daemon stopped" >> "$LOG_FILE"
380
+ log_success "Daemon stopped"
381
+ else
382
+ rm -f "$PID_FILE" "$LOCK_FILE"
383
+ echo "Daemon was not running (stale PID file removed)"
384
+ fi
385
+
386
+ # Stop dashboard if running
387
+ cmd_dashboard_stop
388
+
389
+ # Update state to show inactive
390
+ if [[ -f "$STATE_FILE" ]]; then
391
+ # Use cross-platform sed helper
392
+ sed_inplace 's/status: active/status: stopped/' "$STATE_FILE" 2>/dev/null || true
393
+ fi
394
+ }
395
+
396
+ cmd_status() {
397
+ echo ""
398
+ log_header "🔥 Forge Daemon Status"
399
+
400
+ display_daemon_status
401
+ echo ""
402
+
403
+ display_task_counts
404
+ echo ""
405
+
406
+ display_attention_needed
407
+ display_worker_status
408
+ display_recent_notifications
409
+
410
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
411
+ }
412
+
413
+ cmd_notifications() {
414
+ echo ""
415
+ log_header "🔔 Forge Notifications"
416
+
417
+ if [[ -f "$NOTIFY_FILE" ]]; then
418
+ local count="${1:-10}"
419
+ echo "Last $count notifications:"
420
+ echo ""
421
+ tail -"$count" "$NOTIFY_FILE"
422
+ else
423
+ echo "No notifications yet."
424
+ fi
425
+
426
+ echo ""
427
+ echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
428
+ }
429
+
430
+ cmd_clear_notifications() {
431
+ rm -f "$NOTIFY_FILE" "$NOTIFIED_FILE"
432
+ log_success "Notifications cleared"
433
+ }
434
+
435
+ # =============================================================================
436
+ # Main
437
+ # =============================================================================
438
+
439
+ main() {
440
+ local command="${1:-status}"
441
+
442
+ case "$command" in
443
+ "start")
444
+ cmd_start
445
+ ;;
446
+ "stop")
447
+ cmd_stop
448
+ ;;
449
+ "status")
450
+ cmd_status
451
+ ;;
452
+ "notifications"|"notify")
453
+ shift
454
+ cmd_notifications "$@"
455
+ ;;
456
+ "clear")
457
+ cmd_clear_notifications
458
+ ;;
459
+ "dashboard")
460
+ shift
461
+ case "${1:-start}" in
462
+ "start") cmd_dashboard_start ;;
463
+ "stop") cmd_dashboard_stop ;;
464
+ *)
465
+ echo "Usage: forge-daemon.sh dashboard [start|stop]"
466
+ exit $EXIT_INVALID_ARGUMENT
467
+ ;;
468
+ esac
469
+ ;;
470
+ *)
471
+ echo "Usage: forge-daemon.sh [start|stop|status|notifications|clear|dashboard]"
472
+ exit $EXIT_INVALID_ARGUMENT
473
+ ;;
474
+ esac
475
+ }
476
+
477
+ main "$@"