vibe-forge 0.8.1 → 0.8.2

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,477 +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/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 "$@"
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/../src/lib/colors.sh"
21
+ # shellcheck source=lib/constants.sh
22
+ source "$SCRIPT_DIR/../src/lib/constants.sh"
23
+ # shellcheck source=lib/config.sh
24
+ source "$SCRIPT_DIR/../src/lib/config.sh"
25
+ # shellcheck source=lib/json.sh
26
+ source "$SCRIPT_DIR/../src/lib/json.sh"
27
+ # shellcheck source=lib/database.sh
28
+ source "$SCRIPT_DIR/../src/lib/database.sh"
29
+ # shellcheck source=lib/util.sh
30
+ source "$SCRIPT_DIR/../src/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/../src/lib/daemon/routing.sh"
35
+ # shellcheck source=lib/daemon/notifications.sh
36
+ source "$SCRIPT_DIR/../src/lib/daemon/notifications.sh"
37
+ # shellcheck source=lib/daemon/sync.sh
38
+ source "$SCRIPT_DIR/../src/lib/daemon/sync.sh"
39
+ # shellcheck source=lib/daemon/state.sh
40
+ source "$SCRIPT_DIR/../src/lib/daemon/state.sh"
41
+ # shellcheck source=lib/daemon/display.sh
42
+ source "$SCRIPT_DIR/../src/lib/daemon/display.sh"
43
+ # shellcheck source=lib/daemon/budgets.sh
44
+ source "$SCRIPT_DIR/../src/lib/daemon/budgets.sh"
45
+ # shellcheck source=lib/daemon/dependencies.sh
46
+ source "$SCRIPT_DIR/../src/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 "$@"