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
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bin/lib/daemon/display.sh
4
+ #
5
+ # Status display functions for the daemon status command
6
+ #
7
+ # Dependencies: colors.sh, constants.sh, json.sh
8
+ # Globals required: FORGE_ROOT, PID_FILE, STATE_FILE, NOTIFY_FILE,
9
+ # AGENT_STATUS_DIR, TASKS_ATTENTION, STALE_STATUS_THRESHOLD
10
+
11
+ # Prevent double-sourcing
12
+ [[ -n "${_DAEMON_DISPLAY_LOADED:-}" ]] && return 0
13
+ _DAEMON_DISPLAY_LOADED=1
14
+
15
+ # Display daemon running status (PID check)
16
+ display_daemon_status() {
17
+ if [[ -f "$PID_FILE" ]]; then
18
+ local pid
19
+ pid=$(cat "$PID_FILE")
20
+ if kill -0 "$pid" 2>/dev/null; then
21
+ log_success "Running (PID: $pid)"
22
+ else
23
+ log_warn "Stopped (stale PID file)"
24
+ fi
25
+ else
26
+ echo "Status: Stopped"
27
+ fi
28
+ }
29
+
30
+ # Display task counts from state file
31
+ display_task_counts() {
32
+ if [[ -f "$STATE_FILE" ]]; then
33
+ echo "Task Counts:"
34
+ grep -E "pending:|in_progress:|completed:|in_review:|approved:|needs_changes:|merged:|attention_needed:" "$STATE_FILE" | sed 's/^/ /'
35
+ fi
36
+ }
37
+
38
+ # Display workers needing attention (urgent alerts)
39
+ display_attention_needed() {
40
+ local attention_count
41
+ attention_count=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
42
+ if [[ "$attention_count" -gt 0 ]]; then
43
+ echo -e "${RED}🔔 ATTENTION NEEDED:${NC}"
44
+ for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
45
+ if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
46
+ local agent issue
47
+ agent=$(grep -m1 "^agent:" "$attention_file" 2>/dev/null | cut -d':' -f2 | tr -d ' "' | head -c 50)
48
+ issue=$(sed -n '/^## Issue/,/^##/p' "$attention_file" 2>/dev/null | grep -v "^##" | head -1 | head -c 80)
49
+ echo -e " ${YELLOW}$agent${NC}: $issue"
50
+ fi
51
+ done
52
+ echo ""
53
+ fi
54
+ }
55
+
56
+ # Get status icon for worker status
57
+ get_status_icon() {
58
+ local status="$1"
59
+ case "$status" in
60
+ "working") echo "🔨" ;;
61
+ "idle") echo "💤" ;;
62
+ "blocked") echo "🚫" ;;
63
+ "testing") echo "🧪" ;;
64
+ "reviewing") echo "👁️" ;;
65
+ *) echo "❓" ;;
66
+ esac
67
+ }
68
+
69
+ # Display active worker statuses with staleness indicators
70
+ display_worker_status() {
71
+ if [[ ! -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
72
+ return
73
+ fi
74
+
75
+ local status_count
76
+ status_count=$(find "$FORGE_ROOT/$AGENT_STATUS_DIR" -maxdepth 1 -name "*.json" -type f 2>/dev/null | wc -l)
77
+ if [[ "$status_count" -eq 0 ]]; then
78
+ return
79
+ fi
80
+
81
+ echo "Active Workers:"
82
+ local now_epoch stale_threshold
83
+ now_epoch=$(date +%s)
84
+ stale_threshold=$STALE_STATUS_THRESHOLD
85
+
86
+ for status_file in "$FORGE_ROOT/$AGENT_STATUS_DIR"/*.json; do
87
+ if [[ -f "$status_file" && ! -L "$status_file" ]]; then
88
+ local agent status task updated stale_marker icon
89
+ agent=$(json_read "$status_file" "agent" "unknown")
90
+ status=$(json_read "$status_file" "status" "unknown")
91
+ task=$(json_read "$status_file" "task" "")
92
+ updated=$(json_read "$status_file" "updated" "")
93
+
94
+ # Check staleness
95
+ stale_marker=""
96
+ if [[ -n "$updated" ]]; then
97
+ local updated_epoch age
98
+ 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")
99
+ age=$((now_epoch - updated_epoch))
100
+ if [[ "$age" -gt "$stale_threshold" ]]; then
101
+ stale_marker=" ${YELLOW}(stale)${NC}"
102
+ fi
103
+ fi
104
+
105
+ icon=$(get_status_icon "$status")
106
+
107
+ if [[ -n "$task" ]]; then
108
+ echo -e " $icon ${CYAN}$agent${NC}: $status ($task)$stale_marker"
109
+ else
110
+ echo -e " $icon ${CYAN}$agent${NC}: $status$stale_marker"
111
+ fi
112
+ fi
113
+ done
114
+ echo ""
115
+ }
116
+
117
+ # Display recent notifications from log
118
+ display_recent_notifications() {
119
+ if [[ -f "$NOTIFY_FILE" ]]; then
120
+ local notify_count
121
+ notify_count=$(wc -l < "$NOTIFY_FILE" 2>/dev/null || echo "0")
122
+ if [[ "$notify_count" -gt 0 ]]; then
123
+ echo "Recent Notifications (last 5):"
124
+ tail -5 "$NOTIFY_FILE" | sed 's/^/ /'
125
+ echo ""
126
+ fi
127
+ fi
128
+ }
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bin/lib/daemon/notifications.sh
4
+ #
5
+ # Daemon notification functions - pending task alerts, attention signals,
6
+ # system toasts, and Heimdall escalation handling
7
+ #
8
+ # Dependencies: colors.sh, constants.sh
9
+ # Globals required: FORGE_ROOT, NOTIFY_FILE, NOTIFIED_FILE, LOG_FILE,
10
+ # TASKS_PENDING, TASKS_NEEDS_CHANGES, TASKS_ATTENTION
11
+
12
+ # Prevent double-sourcing
13
+ [[ -n "${_DAEMON_NOTIFICATIONS_LOADED:-}" ]] && return 0
14
+ _DAEMON_NOTIFICATIONS_LOADED=1
15
+
16
+ # Node.js frontmatter helper (RT-20260405-001 MEDIUM-5: replaces grep/cut YAML parsing)
17
+ FRONTMATTER_JS="${FRONTMATTER_JS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/frontmatter.js}"
18
+
19
+ # Parse a single frontmatter field from a markdown file.
20
+ _notif_fm_field() {
21
+ node "$FRONTMATTER_JS" "$1" "$2" 2>/dev/null | sed -n "s/^${2}=//p"
22
+ }
23
+
24
+ # SECURITY: Sanitize message for safe use in shell commands
25
+ # Removes/escapes characters that could cause command injection
26
+ sanitize_notification_message() {
27
+ local msg="$1"
28
+ # Remove null bytes
29
+ msg="${msg//$'\0'/}"
30
+ # Remove characters that could escape out of string contexts
31
+ # PowerShell: $, `, ', ", (), {}
32
+ # osascript: ', ", \, $
33
+ # We allow: alphanumeric, spaces, periods, commas, colons, hyphens, underscores
34
+ msg=$(echo "$msg" | tr -cd 'a-zA-Z0-9 .,;:!?_-')
35
+ # Limit length to prevent buffer issues
36
+ msg="${msg:0:200}"
37
+ echo "$msg"
38
+ }
39
+
40
+ notify() {
41
+ local message="$1"
42
+ local urgency="${2:-normal}" # normal or urgent
43
+ local timestamp
44
+ timestamp=$(date -Iseconds)
45
+
46
+ # SECURITY: Sanitize before logging to prevent ANSI escape injection and
47
+ # control-character log poisoning. sanitize_notification_message() strips
48
+ # everything except alphanumeric, spaces, and safe punctuation.
49
+ local safe_message
50
+ safe_message=$(sanitize_notification_message "$message")
51
+
52
+ # Log to notifications file
53
+ echo "[$timestamp] $safe_message" >> "$NOTIFY_FILE"
54
+
55
+ # Log to main log
56
+ echo "[$timestamp] NOTIFY: $safe_message" >> "$LOG_FILE"
57
+
58
+ # Terminal bell (works in most terminals)
59
+ printf '\a'
60
+
61
+ # System toast notification for urgent messages
62
+ if [[ "$urgency" == "urgent" ]]; then
63
+ send_system_notification "$safe_message"
64
+ fi
65
+ }
66
+
67
+ # Send system-level notification (platform-specific)
68
+ send_system_notification() {
69
+ local message="$1"
70
+ local title="Vibe Forge"
71
+
72
+ # SECURITY: Sanitize message to prevent command injection
73
+ message=$(sanitize_notification_message "$message")
74
+
75
+ case "$(uname -s)" in
76
+ MINGW*|MSYS*|CYGWIN*)
77
+ # Windows: Use PowerShell toast notification
78
+ # SECURITY: Message is sanitized above, title is hardcoded
79
+ powershell.exe -NoProfile -Command "
80
+ \$null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
81
+ \$template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
82
+ \$textNodes = \$template.GetElementsByTagName('text')
83
+ \$textNodes.Item(0).AppendChild(\$template.CreateTextNode('$title')) | Out-Null
84
+ \$textNodes.Item(1).AppendChild(\$template.CreateTextNode('$message')) | Out-Null
85
+ \$toast = [Windows.UI.Notifications.ToastNotification]::new(\$template)
86
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Vibe Forge').Show(\$toast)
87
+ " 2>/dev/null &
88
+ ;;
89
+ Darwin)
90
+ # macOS: Use osascript
91
+ # SECURITY: Message is sanitized above
92
+ osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null &
93
+ ;;
94
+ Linux)
95
+ # Linux: Use notify-send if available
96
+ # SECURITY: notify-send handles escaping, but message is sanitized anyway
97
+ if command -v notify-send &>/dev/null; then
98
+ notify-send "$title" "$message" 2>/dev/null &
99
+ fi
100
+ ;;
101
+ esac
102
+ }
103
+
104
+ check_new_pending_tasks() {
105
+ # Create notified file if it doesn't exist
106
+ touch "$NOTIFIED_FILE"
107
+
108
+ # Check for new pending tasks
109
+ for task in "$FORGE_ROOT/$TASKS_PENDING"/*.md; do
110
+ if [[ -f "$task" && ! -L "$task" ]]; then
111
+ local filename
112
+ filename=$(basename "$task")
113
+
114
+ # Check if we've already notified about this task
115
+ if ! grep -qF "$filename" "$NOTIFIED_FILE" 2>/dev/null; then
116
+ # Extract task info from frontmatter safely
117
+ local task_id task_title assigned_to
118
+
119
+ task_id=$(_notif_fm_field "$task" "id")
120
+ task_title=$(_notif_fm_field "$task" "title")
121
+ assigned_to=$(_notif_fm_field "$task" "assigned_to")
122
+
123
+ # Use filename as fallback
124
+ task_id="${task_id:-$filename}"
125
+ task_title="${task_title:-New task}"
126
+
127
+ # Notify
128
+ if [[ -n "$assigned_to" ]]; then
129
+ notify "New task for $assigned_to: $task_title ($task_id)"
130
+ else
131
+ notify "New pending task: $task_title ($task_id)"
132
+ fi
133
+
134
+ # Mark as notified (atomic append)
135
+ echo "$filename" >> "$NOTIFIED_FILE"
136
+ fi
137
+ fi
138
+ done
139
+
140
+ # Also check needs-changes for tasks that need rework
141
+ for task in "$FORGE_ROOT/$TASKS_NEEDS_CHANGES"/*.md; do
142
+ if [[ -f "$task" && ! -L "$task" ]]; then
143
+ local filename
144
+ filename=$(basename "$task")
145
+ local notified_key="needs-changes:$filename"
146
+
147
+ if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
148
+ local task_id assigned_to
149
+ task_id=$(_notif_fm_field "$task" "id")
150
+ assigned_to=$(_notif_fm_field "$task" "assigned_to")
151
+
152
+ task_id="${task_id:-$filename}"
153
+
154
+ if [[ -n "$assigned_to" ]]; then
155
+ notify "Task needs changes ($assigned_to): $task_id"
156
+ else
157
+ notify "Task needs changes: $task_id"
158
+ fi
159
+
160
+ echo "$notified_key" >> "$NOTIFIED_FILE"
161
+ fi
162
+ fi
163
+ done
164
+ }
165
+
166
+ check_attention_needed() {
167
+ # Check for workers needing attention (urgent notifications)
168
+ if [[ ! -d "$FORGE_ROOT/$TASKS_ATTENTION" ]]; then
169
+ return 0
170
+ fi
171
+
172
+ for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
173
+ if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
174
+ local filename
175
+ filename=$(basename "$attention_file")
176
+ local notified_key="attention:$filename"
177
+
178
+ if ! grep -qF "$notified_key" "$NOTIFIED_FILE" 2>/dev/null; then
179
+ # Extract attention info
180
+ local agent issue
181
+ agent=$(_notif_fm_field "$attention_file" "agent")
182
+ issue=$(node "$FRONTMATTER_JS" --section "$attention_file" "Issue" 2>/dev/null)
183
+ [[ -z "$issue" ]] && issue=$(grep -m1 "^##" "$attention_file" 2>/dev/null | sed 's/^## *//' | head -c 200)
184
+
185
+ agent="${agent:-Unknown}"
186
+ issue="${issue:-Needs attention}"
187
+
188
+ # Ring bell multiple times for attention
189
+ printf '\a\a\a'
190
+
191
+ # Send urgent notification with toast
192
+ notify "🔔 $agent needs help: $issue" "urgent"
193
+
194
+ echo "$notified_key" >> "$NOTIFIED_FILE"
195
+ fi
196
+ fi
197
+ done
198
+ }
199
+
200
+ # check_heimdall_escalations INBOX_DIR
201
+ # Scans the lab worker inbox for .escalation signal files written by Heimdall
202
+ # when a worker accumulates too many policy violations (Gjallarhorn sounded).
203
+ # Writes an escalation handoff for lab sentinel to route to human review.
204
+ check_heimdall_escalations() {
205
+ local inbox_dir="$1"
206
+
207
+ [[ -d "$inbox_dir" ]] || return 0
208
+
209
+ # shopt -s globstar needed for **; fall back to find for compatibility
210
+ local escalation_files
211
+ mapfile -t escalation_files < <(find "$inbox_dir" -name "*.escalation" -type f 2>/dev/null)
212
+
213
+ for escalation_file in "${escalation_files[@]}"; do
214
+ [[ -f "$escalation_file" ]] || continue
215
+
216
+ local story_id agent_dir agent handoff_dir
217
+ story_id=$(basename "$escalation_file" .escalation)
218
+ agent_dir=$(dirname "$escalation_file")
219
+ agent=$(basename "$agent_dir")
220
+
221
+ # Read escalation JSON for violation details
222
+ local violations last_reason
223
+ # SECURITY: Pass file path as argv, not string interpolation, to prevent
224
+ # code injection via crafted filenames (RT-20260405-001 HIGH-1)
225
+ violations=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.violations||'?')}catch(e){console.log('?')}" -- "$escalation_file" 2>/dev/null)
226
+ last_reason=$(node -e "try{const d=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));console.log(d.last_reason||'')}catch(e){console.log('')}" -- "$escalation_file" 2>/dev/null)
227
+
228
+ echo "[$(date -Iseconds)] HEIMDALL SOUNDED: $agent/$story_id ($violations violations) -- writing escalation handoff" >> "$LOG_FILE"
229
+
230
+ # Write escalation handoff for lab sentinel
231
+ # Lab sentinel routes escalations to human review on next cycle
232
+ local handoff_dir="$FORGE_ROOT/_vibe-chain-output/handoffs"
233
+ if [[ -d "$handoff_dir" ]]; then
234
+ local handoff_file="$handoff_dir/${story_id}-worker-escalation.md"
235
+ cat > "$handoff_file" << HANDOFF
236
+ ---
237
+ type: worker-escalation
238
+ story: "$story_id"
239
+ from: forge-daemon
240
+ to: human
241
+ created: $(date -Iseconds)
242
+ status: pending
243
+ agent: "$agent"
244
+ violations: $violations
245
+ ---
246
+
247
+ ## Heimdall Escalation
248
+
249
+ Worker \`$agent\` accumulated $violations policy violations on story \`$story_id\`.
250
+ The worker has been halted for this story. Human review required before resubmitting.
251
+
252
+ ## Last Violation
253
+
254
+ $last_reason
255
+
256
+ ## Audit Log
257
+
258
+ Full violation history: \`_vibe-chain-output/heimdall-audit.log\`
259
+
260
+ ## Action Required
261
+
262
+ Review the audit log and decide:
263
+ - Fix the story spec and resubmit
264
+ - Manually complete the work
265
+ - Cancel the story
266
+ HANDOFF
267
+ echo "[$(date -Iseconds)] Escalation handoff written: $handoff_file" >> "$LOG_FILE"
268
+ fi
269
+
270
+ # Mark escalation as processed so it is not re-processed next cycle
271
+ mv "$escalation_file" "${escalation_file}.processed" 2>/dev/null || true
272
+ done
273
+ }
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bin/lib/daemon/routing.sh
4
+ #
5
+ # Task routing functions - moves tasks between state folders
6
+ #
7
+ # Dependencies: constants.sh, util.sh (log_*), database.sh (db_record_status_history)
8
+ # Globals required: FORGE_ROOT, LOG_FILE, FORGE_DB,
9
+ # TASKS_COMPLETED, TASKS_REVIEW, TASKS_APPROVED, TASKS_MERGED
10
+
11
+ # Prevent double-sourcing
12
+ [[ -n "${_DAEMON_ROUTING_LOADED:-}" ]] && return 0
13
+ _DAEMON_ROUTING_LOADED=1
14
+
15
+ # Safe file move with symlink protection
16
+ safe_move_task() {
17
+ local src="$1"
18
+ local dest_dir="$2"
19
+
20
+ # SECURITY: Skip symlinks to prevent symlink attacks
21
+ if [[ -L "$src" ]]; then
22
+ echo "[$(date -Iseconds)] WARNING: Skipping symlink: $src" >> "$LOG_FILE"
23
+ return 1
24
+ fi
25
+
26
+ # SECURITY: Verify source is a regular file
27
+ if [[ ! -f "$src" ]]; then
28
+ return 1
29
+ fi
30
+
31
+ # SECURITY: Verify destination is within FORGE_ROOT
32
+ local real_dest
33
+ real_dest=$(cd "$dest_dir" 2>/dev/null && pwd)
34
+ local forge_root_real
35
+ forge_root_real=$(cd "$FORGE_ROOT" 2>/dev/null && pwd)
36
+
37
+ if [[ "$real_dest" != "$forge_root_real"/* ]]; then
38
+ echo "[$(date -Iseconds)] ERROR: Destination outside FORGE_ROOT: $dest_dir" >> "$LOG_FILE"
39
+ return 1
40
+ fi
41
+
42
+ local filename
43
+ filename=$(basename "$src")
44
+
45
+ # SECURITY: TOCTOU note - there is a theoretical race between the symlink
46
+ # check above and this mv. A local attacker with precise timing could swap
47
+ # the file for a symlink in that window. This risk is accepted because:
48
+ # 1. Exploiting it requires local filesystem write access (already privileged)
49
+ # 2. The destination boundary check above limits the blast radius
50
+ # 3. A fully atomic check+move in bash would require platform-specific tools
51
+ # Mitigation: the destination is always within FORGE_ROOT (verified above).
52
+ mv "$src" "$dest_dir/$filename"
53
+ }
54
+
55
+ route_completed_to_review() {
56
+ # Move completed tasks to review queue — but ONLY if they have
57
+ # ready_for_review: true in frontmatter. Tasks that were moved
58
+ # to completed/ manually (e.g., already reviewed, historical)
59
+ # should not be re-routed.
60
+ for task in "$FORGE_ROOT/$TASKS_COMPLETED"/*.md; do
61
+ if [[ -f "$task" && ! -L "$task" ]]; then
62
+ # Only route tasks explicitly marked for review
63
+ local ready_for_review
64
+ ready_for_review=$(grep -m1 "^ready_for_review:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d " \"'" | head -c 10)
65
+ [[ "$ready_for_review" == "true" ]] || continue
66
+
67
+ local filename task_id assigned_to
68
+ filename=$(basename "$task")
69
+ task_id=$(grep -m1 "^id:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d " \"'" | head -c 50)
70
+ assigned_to=$(grep -m1 "^assigned_to:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d " \"'" | head -c 50)
71
+ echo "[$(date -Iseconds)] Routing $filename to review" >> "$LOG_FILE"
72
+ if safe_move_task "$task" "$FORGE_ROOT/$TASKS_REVIEW"; then
73
+ db_record_status_history "${assigned_to:-unknown}" "review" "${task_id:-$filename}" 2>/dev/null || true
74
+ fi
75
+ fi
76
+ done
77
+ }
78
+
79
+ route_approved_to_merged() {
80
+ # Move approved tasks to merged archive
81
+ for task in "$FORGE_ROOT/$TASKS_APPROVED"/*.md; do
82
+ if [[ -f "$task" && ! -L "$task" ]]; then
83
+ local filename task_id assigned_to
84
+ filename=$(basename "$task")
85
+ task_id=$(grep -m1 "^id:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d " \"'" | head -c 50)
86
+ assigned_to=$(grep -m1 "^assigned_to:" "$task" 2>/dev/null | cut -d':' -f2 | tr -d " \"'" | head -c 50)
87
+ echo "[$(date -Iseconds)] Archiving $filename to merged" >> "$LOG_FILE"
88
+ if safe_move_task "$task" "$FORGE_ROOT/$TASKS_MERGED"; then
89
+ db_record_status_history "${assigned_to:-unknown}" "merged" "${task_id:-$filename}" 2>/dev/null || true
90
+ fi
91
+ fi
92
+ done
93
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # bin/lib/daemon/state.sh
4
+ #
5
+ # Daemon state management - forge-state.yaml updates, attention tracking,
6
+ # adaptive polling
7
+ #
8
+ # Dependencies: database.sh, constants.sh
9
+ # Requires: sync.sh (for build_worker_status)
10
+ # Globals required: FORGE_ROOT, STATE_FILE, AGENT_STATUS_DIR,
11
+ # TASKS_PENDING, TASKS_IN_PROGRESS, TASKS_COMPLETED,
12
+ # TASKS_REVIEW, TASKS_APPROVED, TASKS_NEEDS_CHANGES,
13
+ # TASKS_MERGED, TASKS_ATTENTION
14
+
15
+ # Prevent double-sourcing
16
+ [[ -n "${_DAEMON_STATE_LOADED:-}" ]] && return 0
17
+ _DAEMON_STATE_LOADED=1
18
+
19
+ # Node.js frontmatter helper (RT-20260405-001 MEDIUM-5: replaces grep/cut YAML parsing)
20
+ FRONTMATTER_JS="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/frontmatter.js"
21
+
22
+ # Parse a single frontmatter field from a markdown file.
23
+ # Usage: fm_field <file> <field>
24
+ fm_field() {
25
+ node "$FRONTMATTER_JS" "$1" "$2" 2>/dev/null | sed -n "s/^${2}=//p"
26
+ }
27
+
28
+ # Parse a markdown section's first content line.
29
+ # Usage: fm_section <file> <heading>
30
+ fm_section() {
31
+ node "$FRONTMATTER_JS" --section "$1" "$2" 2>/dev/null
32
+ }
33
+
34
+ update_state() {
35
+ # Count tasks in each folder (using find with -maxdepth for safety)
36
+ local pending in_progress completed review approved needs_changes merged attention
37
+ pending=$(find "$FORGE_ROOT/$TASKS_PENDING" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
38
+ in_progress=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
39
+ completed=$(find "$FORGE_ROOT/$TASKS_COMPLETED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
40
+ review=$(find "$FORGE_ROOT/$TASKS_REVIEW" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
41
+ approved=$(find "$FORGE_ROOT/$TASKS_APPROVED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
42
+ needs_changes=$(find "$FORGE_ROOT/$TASKS_NEEDS_CHANGES" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
43
+ merged=$(find "$FORGE_ROOT/$TASKS_MERGED" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
44
+ attention=$(find "$FORGE_ROOT/$TASKS_ATTENTION" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
45
+
46
+ local blocked=${BLOCKED_TASK_COUNT:-0}
47
+
48
+ # Build active task details for in-progress tasks
49
+ local active_tasks_details=""
50
+ if [[ "$in_progress" -gt 0 ]]; then
51
+ active_tasks_details=$(build_active_tasks)
52
+ fi
53
+
54
+ # Build attention details if any workers need help
55
+ local attention_details=""
56
+ if [[ "$attention" -gt 0 ]]; then
57
+ attention_details=$(build_attention_details)
58
+ fi
59
+
60
+ # Build blocked task details (T2-H2)
61
+ local blocked_details=""
62
+ if [[ "$blocked" -gt 0 ]]; then
63
+ blocked_details=$(build_blocked_tasks)
64
+ fi
65
+
66
+ # Build worker status from agent-status files
67
+ local worker_status=""
68
+ if [[ -d "$FORGE_ROOT/$AGENT_STATUS_DIR" ]]; then
69
+ worker_status=$(build_worker_status)
70
+ fi
71
+
72
+ # Write state file atomically (write to temp, then move)
73
+ local temp_state="${STATE_FILE}.tmp.$$"
74
+ cat > "$temp_state" << EOF
75
+ # Vibe Forge State
76
+ # Auto-updated by forge-daemon
77
+ # Last updated: $(date -Iseconds)
78
+
79
+ forge:
80
+ status: active
81
+ daemon_pid: $$
82
+
83
+ tasks:
84
+ pending: $pending
85
+ in_progress: $in_progress
86
+ completed: $completed
87
+ in_review: $review
88
+ approved: $approved
89
+ needs_changes: $needs_changes
90
+ merged: $merged
91
+ blocked: $blocked
92
+ attention_needed: $attention
93
+
94
+ $active_tasks_details
95
+ $attention_details
96
+ $blocked_details
97
+ $worker_status
98
+ last_updated: $(date -Iseconds)
99
+ EOF
100
+ mv "$temp_state" "$STATE_FILE"
101
+ }
102
+
103
+ build_attention_details() {
104
+ echo "attention:"
105
+ for attention_file in "$FORGE_ROOT/$TASKS_ATTENTION"/*.md; do
106
+ if [[ -f "$attention_file" && ! -L "$attention_file" ]]; then
107
+ local agent created issue
108
+ agent=$(fm_field "$attention_file" "agent")
109
+ created=$(fm_field "$attention_file" "created")
110
+ issue=$(fm_section "$attention_file" "Issue")
111
+ issue="${issue:-Needs attention}"
112
+
113
+ printf ' - agent: %s\n' "$agent"
114
+ printf ' since: %s\n' "$created"
115
+ printf ' issue: "%s"\n' "$issue"
116
+ fi
117
+ done
118
+ }
119
+
120
+ build_active_tasks() {
121
+ echo "active_tasks:"
122
+ for task_file in "$FORGE_ROOT/$TASKS_IN_PROGRESS"/*.md; do
123
+ if [[ -f "$task_file" && ! -L "$task_file" ]]; then
124
+ local task_id title assigned_to
125
+ task_id=$(fm_field "$task_file" "id")
126
+ title=$(fm_field "$task_file" "title")
127
+ assigned_to=$(fm_field "$task_file" "assigned_to")
128
+
129
+ task_id="${task_id:-$(basename "$task_file" .md)}"
130
+ title="${title:-Untitled}"
131
+ assigned_to="${assigned_to:-unassigned}"
132
+
133
+ printf ' - id: %s\n' "$task_id"
134
+ printf ' title: "%s"\n' "$title"
135
+ printf ' assigned_to: %s\n' "$assigned_to"
136
+ fi
137
+ done
138
+ }
139
+
140
+ # Determine daemon state based on activity (for adaptive polling)
141
+ determine_daemon_state() {
142
+ # Check if there are in-progress tasks
143
+ local in_progress_count
144
+ in_progress_count=$(find "$FORGE_ROOT/$TASKS_IN_PROGRESS" -maxdepth 1 -name "*.md" -type f 2>/dev/null | wc -l)
145
+
146
+ # Check if there are active workers
147
+ local active_workers
148
+ active_workers=$(db_count_active_workers 2>/dev/null || echo "0")
149
+
150
+ if [[ "$in_progress_count" -gt 0 ]] || [[ "$active_workers" -gt 0 ]]; then
151
+ echo "active"
152
+ else
153
+ echo "idle"
154
+ fi
155
+ }
156
+
157
+ # Get current poll interval in seconds (from DB, with fallback)
158
+ get_poll_interval() {
159
+ local interval_ms
160
+ interval_ms=$(db_get_poll_interval_ms 2>/dev/null || echo "30000")
161
+ # Convert ms to seconds (bash integer division)
162
+ echo $((interval_ms / 1000))
163
+ }