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.
- package/.claude/commands/clear-attention.md +63 -63
- package/.claude/commands/compact-context.md +52 -0
- package/.claude/commands/configure-vcs.md +102 -0
- package/.claude/commands/forge.md +218 -171
- package/.claude/commands/need-help.md +77 -77
- package/.claude/commands/update-status.md +64 -64
- package/.claude/commands/worker-loop.md +106 -106
- package/.claude/hooks/worker-loop.js +217 -0
- package/.claude/scripts/setup-worker-loop.sh +45 -45
- package/.claude/settings.json +89 -0
- package/LICENSE +21 -21
- package/README.md +253 -230
- package/agents/aegis/personality.md +303 -269
- package/agents/anvil/personality.md +278 -211
- package/agents/architect/personality.md +260 -0
- package/agents/crucible/personality.md +362 -285
- package/agents/crucible-x/personality.md +210 -0
- package/agents/ember/personality.md +293 -245
- package/agents/flux/personality.md +248 -0
- package/agents/furnace/personality.md +342 -262
- package/agents/herald/personality.md +249 -247
- package/agents/loki/personality.md +108 -0
- package/agents/oracle/personality.md +284 -0
- package/agents/pixel/personality.md +140 -0
- package/agents/planning-hub/personality.md +473 -251
- package/agents/scribe/personality.md +253 -231
- package/agents/slag/personality.md +268 -0
- package/agents/temper/personality.md +270 -0
- package/bin/cli.js +372 -325
- package/bin/dashboard/api/agents.js +333 -0
- package/bin/dashboard/api/dispatch.js +507 -0
- package/bin/dashboard/api/tasks.js +416 -0
- package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
- package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
- package/bin/dashboard/public/index.html +14 -0
- package/bin/dashboard/server.js +645 -0
- package/bin/forge-daemon.sh +477 -775
- package/bin/forge-setup.sh +661 -532
- package/bin/forge-spawn.sh +164 -159
- package/bin/forge.cmd +83 -83
- package/bin/forge.sh +566 -393
- package/bin/lib/agents.sh +177 -177
- package/bin/lib/check-aliases.js +50 -0
- package/bin/lib/colors.sh +44 -44
- package/bin/lib/config.sh +347 -271
- package/bin/lib/constants.sh +241 -171
- package/bin/lib/daemon/budgets.sh +107 -0
- package/bin/lib/daemon/dependencies.sh +146 -0
- package/bin/lib/daemon/display.sh +128 -0
- package/bin/lib/daemon/notifications.sh +273 -0
- package/bin/lib/daemon/routing.sh +93 -0
- package/bin/lib/daemon/state.sh +163 -0
- package/bin/lib/daemon/sync.sh +103 -0
- package/bin/lib/database.sh +357 -224
- package/bin/lib/frontmatter.js +106 -0
- package/bin/lib/heimdall-setup.js +113 -0
- package/bin/lib/heimdall.js +265 -0
- package/bin/lib/json.sh +264 -0
- package/bin/lib/terminal.js +452 -0
- package/bin/lib/util.sh +126 -0
- package/bin/lib/vcs.js +349 -0
- package/config/agent-manifest.yaml +237 -230
- package/config/agents.json +207 -85
- package/config/task-template.md +159 -87
- package/config/task-types.yaml +111 -106
- package/config/templates/handoff-template.md +40 -0
- package/context/agent-overrides/README.md +41 -0
- package/context/architecture.md +42 -0
- package/context/modern-conventions.md +129 -129
- package/context/project-context-template.md +122 -122
- package/docs/agents.md +473 -0
- package/docs/architecture.md +194 -0
- package/docs/commands.md +451 -0
- package/docs/security.md +195 -144
- package/package.json +77 -48
- package/.claude/hooks/worker-loop.sh +0 -141
- package/.claude/settings.local.json +0 -29
- package/agents/forge-master/capabilities.md +0 -144
- package/agents/forge-master/context-template.md +0 -128
- package/agents/forge-master/personality.md +0 -138
- package/agents/sentinel/personality.md +0 -194
- package/context/forge-state.yaml +0 -19
- package/docs/TODO.md +0 -176
- package/docs/npm-publishing.md +0 -95
- 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
|
+
}
|