vibe-forge 0.4.0 → 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 (129) 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 -102
  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 -187
  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 -232
  13. package/agents/aegis/personality.md +303 -269
  14. package/agents/anvil/personality.md +278 -240
  15. package/agents/architect/personality.md +260 -234
  16. package/agents/crucible/personality.md +362 -309
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +293 -265
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +342 -291
  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 -251
  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 -851
  38. package/bin/forge-setup.sh +661 -645
  39. package/bin/forge-spawn.sh +164 -164
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +566 -387
  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 -313
  46. package/bin/lib/constants.sh +241 -206
  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 -305
  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 -258
  59. package/bin/lib/terminal.js +452 -446
  60. package/bin/lib/util.sh +126 -126
  61. package/bin/lib/vcs.js +349 -349
  62. package/config/agent-manifest.yaml +237 -243
  63. package/config/agents.json +207 -132
  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 -409
  72. package/docs/architecture.md +194 -162
  73. package/docs/commands.md +451 -388
  74. package/docs/security.md +195 -144
  75. package/package.json +77 -50
  76. package/.claude/settings.local.json +0 -33
  77. package/agents/forge-master/capabilities.md +0 -144
  78. package/agents/forge-master/context-template.md +0 -128
  79. package/agents/forge-master/personality.md +0 -138
  80. package/agents/sentinel/personality.md +0 -194
  81. package/context/forge-state.yaml +0 -19
  82. package/docs/TODO.md +0 -150
  83. package/docs/getting-started.md +0 -243
  84. package/docs/npm-publishing.md +0 -95
  85. package/docs/workflows/README.md +0 -32
  86. package/docs/workflows/azure-devops.md +0 -108
  87. package/docs/workflows/bitbucket.md +0 -104
  88. package/docs/workflows/git-only.md +0 -130
  89. package/docs/workflows/gitea.md +0 -168
  90. package/docs/workflows/github.md +0 -103
  91. package/docs/workflows/gitlab.md +0 -105
  92. package/docs/workflows.md +0 -454
  93. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  94. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  95. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  96. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  97. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  98. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  99. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  100. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  101. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  102. package/tasks/completed/CLEAN-001.md +0 -38
  103. package/tasks/completed/CLEAN-003.md +0 -47
  104. package/tasks/completed/CLEAN-004.md +0 -56
  105. package/tasks/completed/CLEAN-005.md +0 -75
  106. package/tasks/completed/CLEAN-006.md +0 -47
  107. package/tasks/completed/CLEAN-007.md +0 -34
  108. package/tasks/completed/CLEAN-008.md +0 -49
  109. package/tasks/completed/CLEAN-012.md +0 -58
  110. package/tasks/completed/CLEAN-013.md +0 -45
  111. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  112. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  113. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  114. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  115. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  116. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  117. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  118. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  119. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  120. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  121. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  122. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  123. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  124. package/tasks/pending/CLEAN-002.md +0 -29
  125. package/tasks/pending/CLEAN-009.md +0 -31
  126. package/tasks/pending/CLEAN-010.md +0 -30
  127. package/tasks/pending/CLEAN-011.md +0 -30
  128. package/tasks/pending/CLEAN-014.md +0 -32
  129. package/tasks/review/task-001.md +0 -78
@@ -1,851 +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
- # =============================================================================
33
- # Daemon Configuration
34
- # =============================================================================
35
-
36
- CONFIG_FILE="$FORGE_ROOT/.forge/config.json"
37
- FORGE_DB="$FORGE_ROOT/.forge/forge.db"
38
- PID_FILE="$FORGE_ROOT/.forge/daemon.pid"
39
- LOG_FILE="$FORGE_ROOT/.forge/daemon.log"
40
- NOTIFY_FILE="$FORGE_ROOT/.forge/notifications.log"
41
- NOTIFIED_FILE="$FORGE_ROOT/.forge/notified-tasks.txt"
42
- STATE_FILE="$FORGE_ROOT/$CONTEXT_DIR/forge-state.yaml"
43
- LOCK_FILE="$FORGE_ROOT/.forge/daemon.lock"
44
-
45
- # Log file rotation settings (values defined in constants.sh)
46
- # MAX_LOG_SIZE, MAX_NOTIFY_ENTRIES are loaded from constants.sh
47
-
48
- # Load terminal type from config (safe parsing)
49
- TERMINAL_TYPE="manual"
50
- if [[ -f "$CONFIG_FILE" ]]; then
51
- TERMINAL_TYPE=$(json_get_string "$CONFIG_FILE" "terminal_type") || TERMINAL_TYPE="manual"
52
- fi
53
-
54
- # =============================================================================
55
- # Utility Functions
56
- # =============================================================================
57
-
58
- # Rotate log file if it gets too large
59
- rotate_log_if_needed() {
60
- local log="$1"
61
- if [[ -f "$log" ]]; then
62
- local size
63
- size=$(stat -f%z "$log" 2>/dev/null || stat --format=%s "$log" 2>/dev/null || echo 0)
64
- if [[ "$size" -gt "$MAX_LOG_SIZE" ]]; then
65
- mv "$log" "${log}.old"
66
- touch "$log"
67
- fi
68
- fi
69
- }
70
-
71
- # Trim notification entries to prevent unbounded growth
72
- trim_notified_file() {
73
- if [[ -f "$NOTIFIED_FILE" ]]; then
74
- local count
75
- count=$(wc -l < "$NOTIFIED_FILE" 2>/dev/null || echo 0)
76
- if [[ "$count" -gt "$MAX_NOTIFY_ENTRIES" ]]; then
77
- # Keep last 500 entries
78
- tail -500 "$NOTIFIED_FILE" > "${NOTIFIED_FILE}.tmp"
79
- mv "${NOTIFIED_FILE}.tmp" "$NOTIFIED_FILE"
80
- fi
81
- fi
82
- }
83
-
84
- # Safe file move with symlink protection
85
- safe_move_task() {
86
- local src="$1"
87
- local dest_dir="$2"
88
-
89
- # SECURITY: Skip symlinks to prevent symlink attacks
90
- if [[ -L "$src" ]]; then
91
- echo "[$(date -Iseconds)] WARNING: Skipping symlink: $src" >> "$LOG_FILE"
92
- return 1
93
- fi
94
-
95
- # SECURITY: Verify source is a regular file
96
- if [[ ! -f "$src" ]]; then
97
- return 1
98
- fi
99
-
100
- # SECURITY: Verify destination is within FORGE_ROOT
101
- local real_dest
102
- real_dest=$(cd "$dest_dir" 2>/dev/null && pwd)
103
- local forge_root_real
104
- forge_root_real=$(cd "$FORGE_ROOT" 2>/dev/null && pwd)
105
-
106
- if [[ "$real_dest" != "$forge_root_real"/* ]]; then
107
- echo "[$(date -Iseconds)] ERROR: Destination outside FORGE_ROOT: $dest_dir" >> "$LOG_FILE"
108
- return 1
109
- fi
110
-
111
- local filename
112
- filename=$(basename "$src")
113
- mv "$src" "$dest_dir/$filename"
114
- }
115
-
116
- # =============================================================================
117
- # Notification Functions
118
- # =============================================================================
119
-
120
- # SECURITY: Sanitize message for safe use in shell commands
121
- # Removes/escapes characters that could cause command injection
122
- sanitize_notification_message() {
123
- local msg="$1"
124
- # Remove null bytes
125
- msg="${msg//$'\0'/}"
126
- # Remove characters that could escape out of string contexts
127
- # PowerShell: $, `, ', ", (), {}
128
- # osascript: ', ", \, $
129
- # We allow: alphanumeric, spaces, periods, commas, colons, hyphens, underscores
130
- msg=$(echo "$msg" | tr -cd 'a-zA-Z0-9 .,;:!?_-')
131
- # Limit length to prevent buffer issues
132
- msg="${msg:0:200}"
133
- echo "$msg"
134
- }
135
-
136
- notify() {
137
- local message="$1"
138
- local urgency="${2:-normal}" # normal or urgent
139
- local timestamp
140
- timestamp=$(date -Iseconds)
141
-
142
- # Log to notifications file
143
- echo "[$timestamp] $message" >> "$NOTIFY_FILE"
144
-
145
- # Log to main log
146
- echo "[$timestamp] NOTIFY: $message" >> "$LOG_FILE"
147
-
148
- # Terminal bell (works in most terminals)
149
- printf '\a'
150
-
151
- # System toast notification for urgent messages
152
- if [[ "$urgency" == "urgent" ]]; then
153
- send_system_notification "$message"
154
- fi
155
- }
156
-
157
- # Send system-level notification (platform-specific)
158
- send_system_notification() {
159
- local message="$1"
160
- local title="Vibe Forge"
161
-
162
- # SECURITY: Sanitize message to prevent command injection
163
- message=$(sanitize_notification_message "$message")
164
-
165
- case "$(uname -s)" in
166
- MINGW*|MSYS*|CYGWIN*)
167
- # Windows: Use PowerShell toast notification
168
- # SECURITY: Message is sanitized above, title is hardcoded
169
- powershell.exe -NoProfile -Command "
170
- \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
171
- \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
172
- \$textNodes = \$template.GetElementsByTagName('text')
173
- \$textNodes.Item(0).AppendChild(\$template.CreateTextNode('$title')) | Out-Null
174
- \$textNodes.Item(1).AppendChild(\$template.CreateTextNode('$message')) | Out-Null
175
- \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$template)
176
- [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Vibe Forge').Show(\$toast)
177
- " 2>/dev/null &
178
- ;;
179
- Darwin)
180
- # macOS: Use osascript
181
- # SECURITY: Message is sanitized above
182
- osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null &
183
- ;;
184
- Linux)
185
- # Linux: Use notify-send if available
186
- # SECURITY: notify-send handles escaping, but message is sanitized anyway
187
- if command -v notify-send &>/dev/null; then
188
- notify-send "$title" "$message" 2>/dev/null &
189
- fi
190
- ;;
191
- esac
192
- }
193
-
194
- check_new_pending_tasks() {
195
- # Create notified file if it doesn't exist
196
- touch "$NOTIFIED_FILE"
197
-
198
- # Check for new pending tasks
199
- for task in "$FORGE_ROOT/$TASKS_PENDING"/*.md; do
200
- if [[ -f "$task" && ! -L "$task" ]]; then
201
- local filename
202
- filename=$(basename "$task")
203
-
204
- # Check if we've already notified about this task
205
- if ! grep -qF "$filename" "$NOTIFIED_FILE" 2>/dev/null; then
206
- # Extract task info from frontmatter safely
207
- local task_id task_title assigned_to
208
-
209
- # Use head to limit read, tr to sanitize, and strip ANSI escape sequences
210
- 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)
211
- 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)
212
- 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)
213
-
214
- # Use filename as fallback
215
- task_id="${task_id:-$filename}"
216
- task_title="${task_title:-New task}"
217
-
218
- # Notify
219
- if [[ -n "$assigned_to" ]]; then
220
- notify "New task for $assigned_to: $task_title ($task_id)"
221
- else
222
- notify "New pending task: $task_title ($task_id)"
223
- fi
224
-
225
- # Mark as notified (atomic append)
226
- echo "$filename" >> "$NOTIFIED_FILE"
227
- fi
228
- fi
229
- done
230
-
231
- # Also check needs-changes for tasks that need rework
232
- for task in "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"/*.md; do
233
- if [[ -f "$task" && ! -L "$task" ]]; then
234
- local filename
235
- filename=$(basename "$task")
236
- local notified_key="needs-changes:$filename"
237
-
238
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
239
- local task_id assigned_to
240
- task_id=$(grep -m1 "^id:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 100)
241
- assigned_to=$(grep -m1 "^assigned_to:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
242
-
243
- task_id="${task_id:-$filename}"
244
-
245
- if [[ -n "$assigned_to" ]]; then
246
- notify "Task needs changes ($assigned_to): $task_id"
247
- else
248
- notify "Task needs changes: $task_id"
249
- fi
250
-
251
- echo "$notified_key" >> "$NOTIFIED_FILE"
252
- fi
253
- fi
254
- done
255
- }
256
-
257
- check_attention_needed() {
258
- # Check for workers needing attention (urgent notifications)
259
- if [[ ! -d "$FORGE_ROOT/$TASKS_ATTENTION" ]]; then
260
- return 0
261
- fi
262
-
263
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
264
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
265
- local filename
266
- filename=$(basename "$attention_file")
267
- local notified_key="attention:$filename"
268
-
269
- if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
270
- # Extract attention info
271
- local agent issue
272
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
273
- issue=$(grep -m1 "^##" "$attention_file" 2>/dev/null | sed 's/^## *//' | head -c 200)
274
-
275
- agent="${agent:-Unknown}"
276
- issue="${issue:-Needs attention}"
277
-
278
- # Ring bell multiple times for attention
279
- printf '\a\a\a'
280
-
281
- # Send urgent notification with toast
282
- notify "🔔 $agent needs help: $issue" "urgent"
283
-
284
- echo "$notified_key" >> "$NOTIFIED_FILE"
285
- fi
286
- fi
287
- done
288
- }
289
-
290
- # Sync agent status from JSON files to SQLite (with mtime filtering)
291
- sync_agent_status_to_db() {
292
- local status_dir="$FORGE_ROOT/$AGENT_STATUS_DIR"
293
-
294
- if [[ ! -d "$status_dir" ]]; then
295
- return 0
296
- fi
297
-
298
- for status_file in "$status_dir"/*.json; do
299
- if [[ -f "$status_file" && ! -L "$status_file" ]]; then
300
- # Get file modification time
301
- local file_mtime
302
- file_mtime=$(stat -c %Y "$status_file" 2>/dev/null || stat -f %m "$status_file" 2>/dev/null || echo "0")
303
-
304
- # Get agent name from filename
305
- local agent_name
306
- agent_name=$(basename "$status_file" .json)
307
-
308
- # Check if file has changed since last read
309
- local stored_mtime
310
- stored_mtime=$(db_get_agent_mtime "$agent_name")
311
-
312
- if [[ "$file_mtime" -gt "$stored_mtime" ]]; then
313
- # File changed - parse and update DB
314
- local agent status task message updated
315
- agent=$(json_read "$status_file" "agent" "unknown")
316
- status=$(json_read "$status_file" "status" "unknown")
317
- task=$(json_read "$status_file" "task" "")
318
- message=$(json_read "$status_file" "message" "" | head -c 80)
319
- updated=$(json_read "$status_file" "updated" "")
320
-
321
- # Upsert to database
322
- db_upsert_agent_status "$agent" "$status" "$task" "$message" "$updated" "$file_mtime"
323
-
324
- echo "[$(date -Iseconds)] Synced status for $agent: $status" >> "$LOG_FILE"
325
- fi
326
- fi
327
- done
328
- }
329
-
330
- # Build worker status from SQLite (for YAML output)
331
- build_worker_status() {
332
- local now_epoch
333
- now_epoch=$(date +%s)
334
- local stale_threshold=$STALE_STATUS_THRESHOLD
335
-
336
- # Check if we have any agent status in DB
337
- local agent_count
338
- agent_count=$(sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM agent_status;" 2>/dev/null || echo "0")
339
-
340
- if [[ "$agent_count" -eq 0 ]]; then
341
- return 0
342
- fi
343
-
344
- echo "workers:"
345
-
346
- # Read from database
347
- while IFS='|' read -r agent status task message updated; do
348
- local stale_marker=""
349
-
350
- # Check if stale
351
- if [[ -n "$updated" ]]; then
352
- local updated_epoch age
353
- 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")
354
- age=$((now_epoch - updated_epoch))
355
- if [[ "$age" -gt "$stale_threshold" ]]; then
356
- stale_marker=" (stale)"
357
- fi
358
- fi
359
-
360
- echo " - agent: $agent"
361
- echo " status: $status$stale_marker"
362
- if [[ -n "$task" ]]; then
363
- echo " task: $task"
364
- fi
365
- if [[ -n "$message" ]]; then
366
- echo " message: \"$message\""
367
- fi
368
- echo " updated: $updated"
369
- done < <(db_get_all_agent_statuses)
370
- }
371
-
372
- # =============================================================================
373
- # Daemon Functions
374
- # =============================================================================
375
-
376
- update_state() {
377
- # Count tasks in each folder (using find with -maxdepth for safety)
378
- local pending in_progress completed review approved needs_changes merged attention
379
- pending=$(find "$FORGE_ROOT/$TASKS_PENDING" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
380
- in_progress=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
381
- completed=$(find "$FORGE_ROOT/$TASKS_COMPLETED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
382
- review=$(find "$FORGE_ROOT/$TASKS_REVIEW" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
383
- approved=$(find "$FORGE_ROOT/$TASKS_APPROVED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
384
- needs_changes=$(find "$FORGE_ROOT/$TASKS_NEEDS_CHANGES" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
385
- merged=$(find "$FORGE_ROOT/$TASKS_MERGED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
386
- attention=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
387
-
388
- local blocked=0
389
-
390
- # Build attention details if any workers need help
391
- local attention_details=""
392
- if [[ "$attention" -gt 0 ]]; then
393
- attention_details=$(build_attention_details)
394
- fi
395
-
396
- # Build worker status from agent-status files
397
- local worker_status=""
398
- if [[ -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
399
- worker_status=$(build_worker_status)
400
- fi
401
-
402
- # Write state file atomically (write to temp, then move)
403
- local temp_state="${STATE_FILE}.tmp.$$"
404
- cat > "$temp_state" << EOF
405
- # Vibe Forge State
406
- # Auto-updated by forge-daemon
407
- # Last updated: $(date -Iseconds)
408
-
409
- forge:
410
- status: active
411
- daemon_pid: $$
412
-
413
- tasks:
414
- pending: $pending
415
- in_progress: $in_progress
416
- completed: $completed
417
- in_review: $review
418
- approved: $approved
419
- needs_changes: $needs_changes
420
- merged: $merged
421
- blocked: $blocked
422
- attention_needed: $attention
423
-
424
- $attention_details
425
- $worker_status
426
- last_updated: $(date -Iseconds)
427
- EOF
428
- mv "$temp_state" "$STATE_FILE"
429
- }
430
-
431
- build_attention_details() {
432
- echo "attention:"
433
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
434
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
435
- local agent created issue
436
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
437
- created=$(grep -m1 "^created:" "$attention_file" 2>/dev/null | cut -d':' -f2- | tr -d ' ' | head -c 30)
438
- # Get the issue line (first ## heading content or fallback)
439
- issue=$(sed -n '/^## Issue/,/^##/p' "$attention_file" 2>/dev/null | grep -v "^##" | head -1 | tr -d '\n' | head -c 100)
440
- issue="${issue:-Needs attention}"
441
-
442
- echo " - agent: $agent"
443
- echo " since: $created"
444
- echo " issue: \"$issue\""
445
- fi
446
- done
447
- }
448
-
449
- route_completed_to_review() {
450
- # Move completed tasks to review queue
451
- for task in "$FORGE_ROOT/$TASKS_COMPLETED"/*.md; do
452
- if [[ -f "$task" && ! -L "$task" ]]; then
453
- local filename
454
- filename=$(basename "$task")
455
- echo "[$(date -Iseconds)] Routing $filename to review" >> "$LOG_FILE"
456
- safe_move_task "$task" "$FORGE_ROOT/$TASKS_REVIEW"
457
- fi
458
- done
459
- }
460
-
461
- route_approved_to_merged() {
462
- # Move approved tasks to merged archive
463
- for task in "$FORGE_ROOT/$TASKS_APPROVED"/*.md; do
464
- if [[ -f "$task" && ! -L "$task" ]]; then
465
- local filename
466
- filename=$(basename "$task")
467
- echo "[$(date -Iseconds)] Archiving $filename to merged" >> "$LOG_FILE"
468
- safe_move_task "$task" "$FORGE_ROOT/$TASKS_MERGED"
469
- fi
470
- done
471
- }
472
-
473
- # Determine daemon state based on activity (for adaptive polling)
474
- determine_daemon_state() {
475
- # Check if there are in-progress tasks
476
- local in_progress_count
477
- in_progress_count=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
478
-
479
- # Check if there are active workers
480
- local active_workers
481
- active_workers=$(db_count_active_workers 2>/dev/null || echo "0")
482
-
483
- if [[ "$in_progress_count" -gt 0 ]] || [[ "$active_workers" -gt 0 ]]; then
484
- echo "active"
485
- else
486
- echo "idle"
487
- fi
488
- }
489
-
490
- # Get current poll interval in seconds (from DB, with fallback)
491
- get_poll_interval() {
492
- local interval_ms
493
- interval_ms=$(db_get_poll_interval_ms 2>/dev/null || echo "30000")
494
- # Convert ms to seconds (bash integer division)
495
- echo $((interval_ms / 1000))
496
- }
497
-
498
- daemon_loop() {
499
- echo "[$(date -Iseconds)] Forge daemon started (PID: $$)" >> "$LOG_FILE"
500
-
501
- # Create lock file
502
- echo $$ > "$LOCK_FILE"
503
-
504
- # Initialize database
505
- db_init
506
- echo "[$(date -Iseconds)] Database initialized at $FORGE_DB" >> "$LOG_FILE"
507
-
508
- # Cleanup on exit
509
- trap 'rm -f "$LOCK_FILE"; echo "[$(date -Iseconds)] Daemon exiting" >> "$LOG_FILE"' EXIT
510
-
511
- local iteration=0
512
- local current_state="idle"
513
- local poll_interval=2 # Start fast, adjust based on activity
514
-
515
- while true; do
516
- # Increment iteration counter
517
- ((iteration++)) || true
518
-
519
- # Sync agent status from JSON files to SQLite (with mtime filtering)
520
- sync_agent_status_to_db
521
-
522
- # Check for new tasks and notify
523
- check_new_pending_tasks
524
-
525
- # Check for workers needing attention (urgent)
526
- check_attention_needed
527
-
528
- # Route tasks
529
- route_completed_to_review
530
- route_approved_to_merged
531
-
532
- # Update state file
533
- update_state
534
-
535
- # Adaptive polling: check activity and adjust interval
536
- local new_state
537
- new_state=$(determine_daemon_state)
538
- if [[ "$new_state" != "$current_state" ]]; then
539
- current_state="$new_state"
540
- db_set_daemon_state "$current_state"
541
- poll_interval=$(get_poll_interval)
542
- echo "[$(date -Iseconds)] State changed to $current_state, poll interval: ${poll_interval}s" >> "$LOG_FILE"
543
- fi
544
-
545
- # Periodic maintenance (every MAINTENANCE_INTERVAL iterations)
546
- if [[ $((iteration % MAINTENANCE_INTERVAL)) -eq 0 ]]; then
547
- rotate_log_if_needed "$LOG_FILE"
548
- rotate_log_if_needed "$NOTIFY_FILE"
549
- trim_notified_file
550
- # Cleanup stale agent status
551
- db_cleanup_stale_agents "$STALE_CLEANUP_MINUTES"
552
- # Prune old history
553
- db_prune_history "$HISTORY_PRUNE_DAYS"
554
- fi
555
-
556
- sleep "$poll_interval"
557
- done
558
- }
559
-
560
- # =============================================================================
561
- # Commands
562
- # =============================================================================
563
-
564
- cmd_start() {
565
- # Create directories if needed (with secure permissions)
566
- mkdir -p "$FORGE_ROOT/.forge"
567
- chmod 700 "$FORGE_ROOT/.forge"
568
-
569
- # SECURITY: Use flock for atomic lock acquisition to prevent TOCTOU race
570
- # This prevents multiple daemon instances from starting simultaneously
571
- local lock_fd=200
572
- local startup_lock="$FORGE_ROOT/.forge/startup.lock"
573
-
574
- # Try to acquire exclusive lock (non-blocking)
575
- if command -v flock &>/dev/null; then
576
- # flock available (Linux, some Git Bash installations)
577
- exec 200>"$startup_lock"
578
- if ! flock -n 200; then
579
- echo "Another daemon startup is in progress"
580
- return 1
581
- fi
582
- # Lock acquired - will be released when subshell exits or fd closes
583
- fi
584
- # If flock not available, fall back to PID-based check (less secure but functional)
585
-
586
- # Check if already running
587
- if [[ -f "$PID_FILE" ]]; then
588
- local pid
589
- pid=$(cat "$PID_FILE")
590
- if kill -0 "$pid" 2>/dev/null; then
591
- echo "Daemon already running (PID: $pid)"
592
- return 0
593
- else
594
- # Stale PID file
595
- rm -f "$PID_FILE"
596
- fi
597
- fi
598
-
599
- # Check for lock file (another instance check - defense in depth)
600
- if [[ -f "$LOCK_FILE" ]]; then
601
- local lock_pid
602
- lock_pid=$(cat "$LOCK_FILE" 2>/dev/null)
603
- if kill -0 "$lock_pid" 2>/dev/null; then
604
- echo "Another daemon instance is running (PID: $lock_pid)"
605
- return 1
606
- else
607
- rm -f "$LOCK_FILE"
608
- fi
609
- fi
610
-
611
- mkdir -p "$FORGE_ROOT/$TASKS_PENDING"
612
- mkdir -p "$FORGE_ROOT/$TASKS_IN_PROGRESS"
613
- mkdir -p "$FORGE_ROOT/$TASKS_COMPLETED"
614
- mkdir -p "$FORGE_ROOT/$TASKS_REVIEW"
615
- mkdir -p "$FORGE_ROOT/$TASKS_APPROVED"
616
- mkdir -p "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"
617
- mkdir -p "$FORGE_ROOT/$TASKS_MERGED"
618
- mkdir -p "$FORGE_ROOT/$TASKS_ATTENTION"
619
- mkdir -p "$FORGE_ROOT/$AGENT_STATUS_DIR"
620
-
621
- # Start daemon in background
622
- daemon_loop &
623
- local pid=$!
624
- echo "$pid" > "$PID_FILE"
625
-
626
- log_success "Forge daemon started (PID: $pid)"
627
- echo " Log: $LOG_FILE"
628
-
629
- # Note: flock is automatically released when the fd is closed (script exits)
630
- }
631
-
632
- cmd_stop() {
633
- if [[ ! -f "$PID_FILE" ]]; then
634
- echo "Daemon not running"
635
- return 0
636
- fi
637
-
638
- local pid
639
- pid=$(cat "$PID_FILE")
640
-
641
- if kill -0 "$pid" 2>/dev/null; then
642
- kill "$pid"
643
- rm -f "$PID_FILE" "$LOCK_FILE"
644
- echo "[$(date -Iseconds)] Forge daemon stopped" >> "$LOG_FILE"
645
- log_success "Daemon stopped"
646
- else
647
- rm -f "$PID_FILE" "$LOCK_FILE"
648
- echo "Daemon was not running (stale PID file removed)"
649
- fi
650
-
651
- # Update state to show inactive
652
- if [[ -f "$STATE_FILE" ]]; then
653
- # Use cross-platform sed helper
654
- sed_inplace 's/status: active/status: stopped/' "$STATE_FILE" 2>/dev/null || true
655
- fi
656
- }
657
-
658
- # =============================================================================
659
- # Status Display Helper Functions
660
- # =============================================================================
661
-
662
- # Display daemon running status (PID check)
663
- display_daemon_status() {
664
- if [[ -f "$PID_FILE" ]]; then
665
- local pid
666
- pid=$(cat "$PID_FILE")
667
- if kill -0 "$pid" 2>/dev/null; then
668
- log_success "Running (PID: $pid)"
669
- else
670
- log_warn "Stopped (stale PID file)"
671
- fi
672
- else
673
- echo "Status: Stopped"
674
- fi
675
- }
676
-
677
- # Display task counts from state file
678
- display_task_counts() {
679
- if [[ -f "$STATE_FILE" ]]; then
680
- echo "Task Counts:"
681
- grep -E "pending:|in_progress:|completed:|in_review:|approved:|needs_changes:|merged:|attention_needed:" "$STATE_FILE" | sed 's/^/ /'
682
- fi
683
- }
684
-
685
- # Display workers needing attention (urgent alerts)
686
- display_attention_needed() {
687
- local attention_count
688
- attention_count=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
689
- if [[ "$attention_count" -gt 0 ]]; then
690
- echo -e "${RED}🔔 ATTENTION NEEDED:${NC}"
691
- for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
692
- if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
693
- local agent issue
694
- agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
695
- issue=$(sed -n '/^## Issue/,/^##/p' "$attention_file" 2>/dev/null | grep -v "^##" | head -1 | head -c 80)
696
- echo -e " ${YELLOW}$agent${NC}: $issue"
697
- fi
698
- done
699
- echo ""
700
- fi
701
- }
702
-
703
- # Get status icon for worker status
704
- get_status_icon() {
705
- local status="$1"
706
- case "$status" in
707
- "working") echo "🔨" ;;
708
- "idle") echo "💤" ;;
709
- "blocked") echo "🚫" ;;
710
- "testing") echo "🧪" ;;
711
- "reviewing") echo "👁️" ;;
712
- *) echo "❓" ;;
713
- esac
714
- }
715
-
716
- # Display active worker statuses with staleness indicators
717
- display_worker_status() {
718
- if [[ ! -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
719
- return
720
- fi
721
-
722
- local status_count
723
- status_count=$(find "$FORGE_ROOT/$AGENT_STATUS_DIR" -maxdepth 1 -name "*.json" -type f 2>/dev/null | wc -l)
724
- if [[ "$status_count" -eq 0 ]]; then
725
- return
726
- fi
727
-
728
- echo "Active Workers:"
729
- local now_epoch stale_threshold
730
- now_epoch=$(date +%s)
731
- stale_threshold=$STALE_STATUS_THRESHOLD
732
-
733
- for status_file in "$FORGE_ROOT/$AGENT_STATUS_DIR"/*.json; do
734
- if [[ -f "$status_file" && ! -L "$status_file" ]]; then
735
- local agent status task updated stale_marker icon
736
- agent=$(json_read "$status_file" "agent" "unknown")
737
- status=$(json_read "$status_file" "status" "unknown")
738
- task=$(json_read "$status_file" "task" "")
739
- updated=$(json_read "$status_file" "updated" "")
740
-
741
- # Check staleness
742
- stale_marker=""
743
- if [[ -n "$updated" ]]; then
744
- local updated_epoch age
745
- 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")
746
- age=$((now_epoch - updated_epoch))
747
- if [[ "$age" -gt "$stale_threshold" ]]; then
748
- stale_marker=" ${YELLOW}(stale)${NC}"
749
- fi
750
- fi
751
-
752
- icon=$(get_status_icon "$status")
753
-
754
- if [[ -n "$task" ]]; then
755
- echo -e " $icon ${CYAN}$agent${NC}: $status ($task)$stale_marker"
756
- else
757
- echo -e " $icon ${CYAN}$agent${NC}: $status$stale_marker"
758
- fi
759
- fi
760
- done
761
- echo ""
762
- }
763
-
764
- # Display recent notifications from log
765
- display_recent_notifications() {
766
- if [[ -f "$NOTIFY_FILE" ]]; then
767
- local notify_count
768
- notify_count=$(wc -l < "$NOTIFY_FILE" 2>/dev/null || echo "0")
769
- if [[ "$notify_count" -gt 0 ]]; then
770
- echo "Recent Notifications (last 5):"
771
- tail -5 "$NOTIFY_FILE" | sed 's/^/ /'
772
- echo ""
773
- fi
774
- fi
775
- }
776
-
777
- # =============================================================================
778
- # Main Status Command
779
- # =============================================================================
780
-
781
- cmd_status() {
782
- echo ""
783
- log_header "🔥 Forge Daemon Status"
784
-
785
- display_daemon_status
786
- echo ""
787
-
788
- display_task_counts
789
- echo ""
790
-
791
- display_attention_needed
792
- display_worker_status
793
- display_recent_notifications
794
-
795
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
796
- }
797
-
798
- cmd_notifications() {
799
- echo ""
800
- log_header "🔔 Forge Notifications"
801
-
802
- if [[ -f "$NOTIFY_FILE" ]]; then
803
- local count="${1:-10}"
804
- echo "Last $count notifications:"
805
- echo ""
806
- tail -"$count" "$NOTIFY_FILE"
807
- else
808
- echo "No notifications yet."
809
- fi
810
-
811
- echo ""
812
- echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
813
- }
814
-
815
- cmd_clear_notifications() {
816
- rm -f "$NOTIFY_FILE" "$NOTIFIED_FILE"
817
- log_success "Notifications cleared"
818
- }
819
-
820
- # =============================================================================
821
- # Main
822
- # =============================================================================
823
-
824
- main() {
825
- local command="${1:-status}"
826
-
827
- case "$command" in
828
- "start")
829
- cmd_start
830
- ;;
831
- "stop")
832
- cmd_stop
833
- ;;
834
- "status")
835
- cmd_status
836
- ;;
837
- "notifications"|"notify")
838
- shift
839
- cmd_notifications "$@"
840
- ;;
841
- "clear")
842
- cmd_clear_notifications
843
- ;;
844
- *)
845
- echo "Usage: forge-daemon.sh [start|stop|status|notifications|clear]"
846
- exit $EXIT_INVALID_ARGUMENT
847
- ;;
848
- esac
849
- }
850
-
851
- 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 "$@"