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.
- package/.claude/commands/clear-attention.md +63 -63
- package/.claude/commands/compact-context.md +52 -0
- package/.claude/commands/configure-vcs.md +102 -102
- 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 -187
- package/.claude/scripts/setup-worker-loop.sh +45 -45
- package/.claude/settings.json +89 -0
- package/LICENSE +21 -21
- package/README.md +253 -232
- package/agents/aegis/personality.md +303 -269
- package/agents/anvil/personality.md +278 -240
- package/agents/architect/personality.md +260 -234
- package/agents/crucible/personality.md +362 -309
- package/agents/crucible-x/personality.md +210 -0
- package/agents/ember/personality.md +293 -265
- package/agents/flux/personality.md +248 -0
- package/agents/furnace/personality.md +342 -291
- 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 -251
- 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 -851
- package/bin/forge-setup.sh +661 -645
- package/bin/forge-spawn.sh +164 -164
- package/bin/forge.cmd +83 -83
- package/bin/forge.sh +566 -387
- 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 -313
- package/bin/lib/constants.sh +241 -206
- 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 -305
- 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 -258
- package/bin/lib/terminal.js +452 -446
- package/bin/lib/util.sh +126 -126
- package/bin/lib/vcs.js +349 -349
- package/config/agent-manifest.yaml +237 -243
- package/config/agents.json +207 -132
- 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 -409
- package/docs/architecture.md +194 -162
- package/docs/commands.md +451 -388
- package/docs/security.md +195 -144
- package/package.json +77 -50
- package/.claude/settings.local.json +0 -33
- 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 -150
- package/docs/getting-started.md +0 -243
- package/docs/npm-publishing.md +0 -95
- package/docs/workflows/README.md +0 -32
- package/docs/workflows/azure-devops.md +0 -108
- package/docs/workflows/bitbucket.md +0 -104
- package/docs/workflows/git-only.md +0 -130
- package/docs/workflows/gitea.md +0 -168
- package/docs/workflows/github.md +0 -103
- package/docs/workflows/gitlab.md +0 -105
- package/docs/workflows.md +0 -454
- package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
- package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
- package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
- package/tasks/completed/ARCH-009-test-organization.md +0 -78
- package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
- package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
- package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
- package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
- package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
- package/tasks/completed/CLEAN-001.md +0 -38
- package/tasks/completed/CLEAN-003.md +0 -47
- package/tasks/completed/CLEAN-004.md +0 -56
- package/tasks/completed/CLEAN-005.md +0 -75
- package/tasks/completed/CLEAN-006.md +0 -47
- package/tasks/completed/CLEAN-007.md +0 -34
- package/tasks/completed/CLEAN-008.md +0 -49
- package/tasks/completed/CLEAN-012.md +0 -58
- package/tasks/completed/CLEAN-013.md +0 -45
- package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
- package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
- package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
- package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
- package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
- package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
- package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
- package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
- package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
- package/tasks/pending/ARCH-006-task-template-location.md +0 -64
- package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
- package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
- package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
- package/tasks/pending/CLEAN-002.md +0 -29
- package/tasks/pending/CLEAN-009.md +0 -31
- package/tasks/pending/CLEAN-010.md +0 -30
- package/tasks/pending/CLEAN-011.md +0 -30
- package/tasks/pending/CLEAN-014.md +0 -32
- package/tasks/review/task-001.md +0 -78
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# bin/lib/daemon/dependencies.sh
|
|
4
|
+
#
|
|
5
|
+
# Task dependency resolution (T2-H2)
|
|
6
|
+
#
|
|
7
|
+
# Checks blocked_by fields in pending task frontmatter. Tasks whose
|
|
8
|
+
# blockers are all resolved (in completed/ or merged/) are eligible
|
|
9
|
+
# for dispatch. Tasks with unresolved blockers are surfaced in the
|
|
10
|
+
# state file so Planning Hub and the dashboard can show them.
|
|
11
|
+
#
|
|
12
|
+
# Dependencies: frontmatter.js, constants.sh
|
|
13
|
+
# Globals required: FORGE_ROOT, TASKS_PENDING, TASKS_COMPLETED,
|
|
14
|
+
# TASKS_MERGED, LOG_FILE
|
|
15
|
+
|
|
16
|
+
# Prevent double-sourcing
|
|
17
|
+
[[ -n "${_DAEMON_DEPENDENCIES_LOADED:-}" ]] && return 0
|
|
18
|
+
_DAEMON_DEPENDENCIES_LOADED=1
|
|
19
|
+
|
|
20
|
+
# Node.js frontmatter helper
|
|
21
|
+
FRONTMATTER_JS="${FRONTMATTER_JS:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/frontmatter.js}"
|
|
22
|
+
|
|
23
|
+
# Count of currently blocked tasks (used by state.sh)
|
|
24
|
+
BLOCKED_TASK_COUNT=0
|
|
25
|
+
|
|
26
|
+
# Check if a task ID exists in completed or merged directories
|
|
27
|
+
_is_task_resolved() {
|
|
28
|
+
local task_id="$1"
|
|
29
|
+
local completed_dir="$FORGE_ROOT/$TASKS_COMPLETED"
|
|
30
|
+
local merged_dir="$FORGE_ROOT/$TASKS_MERGED"
|
|
31
|
+
|
|
32
|
+
# Check completed/
|
|
33
|
+
for f in "$completed_dir"/*.md; do
|
|
34
|
+
[[ -f "$f" ]] || continue
|
|
35
|
+
if grep -q "^id: *${task_id}$" "$f" 2>/dev/null; then
|
|
36
|
+
return 0
|
|
37
|
+
fi
|
|
38
|
+
done
|
|
39
|
+
|
|
40
|
+
# Check merged/
|
|
41
|
+
for f in "$merged_dir"/*.md; do
|
|
42
|
+
[[ -f "$f" ]] || continue
|
|
43
|
+
if grep -q "^id: *${task_id}$" "$f" 2>/dev/null; then
|
|
44
|
+
return 0
|
|
45
|
+
fi
|
|
46
|
+
done
|
|
47
|
+
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Check all pending tasks for unresolved blockers.
|
|
52
|
+
# Sets BLOCKED_TASK_COUNT and outputs YAML for blocked tasks.
|
|
53
|
+
check_task_dependencies() {
|
|
54
|
+
BLOCKED_TASK_COUNT=0
|
|
55
|
+
local pending_dir="$FORGE_ROOT/$TASKS_PENDING"
|
|
56
|
+
|
|
57
|
+
[[ -d "$pending_dir" ]] || return 0
|
|
58
|
+
|
|
59
|
+
for task_file in "$pending_dir"/*.md; do
|
|
60
|
+
[[ -f "$task_file" && ! -L "$task_file" ]] || continue
|
|
61
|
+
|
|
62
|
+
# Extract blocked_by field (comma or space separated list of task IDs)
|
|
63
|
+
local blocked_by
|
|
64
|
+
blocked_by=$(node "$FRONTMATTER_JS" "$task_file" "blocked_by" 2>/dev/null | sed -n 's/^blocked_by=//p')
|
|
65
|
+
|
|
66
|
+
[[ -z "$blocked_by" ]] && continue
|
|
67
|
+
|
|
68
|
+
# Parse the list (handle comma-separated, space-separated, or YAML array)
|
|
69
|
+
local blockers
|
|
70
|
+
blockers=$(echo "$blocked_by" | tr ',[]' ' ' | tr -s ' ')
|
|
71
|
+
|
|
72
|
+
local unresolved=""
|
|
73
|
+
local all_resolved=true
|
|
74
|
+
|
|
75
|
+
for blocker_id in $blockers; do
|
|
76
|
+
# Skip empty tokens
|
|
77
|
+
[[ -z "$blocker_id" ]] && continue
|
|
78
|
+
|
|
79
|
+
if ! _is_task_resolved "$blocker_id"; then
|
|
80
|
+
all_resolved=false
|
|
81
|
+
if [[ -n "$unresolved" ]]; then
|
|
82
|
+
unresolved="$unresolved, $blocker_id"
|
|
83
|
+
else
|
|
84
|
+
unresolved="$blocker_id"
|
|
85
|
+
fi
|
|
86
|
+
fi
|
|
87
|
+
done
|
|
88
|
+
|
|
89
|
+
if [[ "$all_resolved" == "false" ]]; then
|
|
90
|
+
((BLOCKED_TASK_COUNT++)) || true
|
|
91
|
+
local task_id
|
|
92
|
+
task_id=$(node "$FRONTMATTER_JS" "$task_file" "id" 2>/dev/null | sed -n 's/^id=//p')
|
|
93
|
+
task_id="${task_id:-$(basename "$task_file" .md)}"
|
|
94
|
+
echo "[$(date -Iseconds)] BLOCKED: $task_id waiting on: $unresolved" >> "$LOG_FILE"
|
|
95
|
+
fi
|
|
96
|
+
done
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# Build YAML section for blocked tasks (called by state.sh update_state)
|
|
100
|
+
build_blocked_tasks() {
|
|
101
|
+
local pending_dir="$FORGE_ROOT/$TASKS_PENDING"
|
|
102
|
+
|
|
103
|
+
[[ -d "$pending_dir" ]] || return 0
|
|
104
|
+
|
|
105
|
+
local has_blocked=false
|
|
106
|
+
|
|
107
|
+
for task_file in "$pending_dir"/*.md; do
|
|
108
|
+
[[ -f "$task_file" && ! -L "$task_file" ]] || continue
|
|
109
|
+
|
|
110
|
+
local blocked_by
|
|
111
|
+
blocked_by=$(node "$FRONTMATTER_JS" "$task_file" "blocked_by" 2>/dev/null | sed -n 's/^blocked_by=//p')
|
|
112
|
+
|
|
113
|
+
[[ -z "$blocked_by" ]] && continue
|
|
114
|
+
|
|
115
|
+
local blockers unresolved_list=""
|
|
116
|
+
blockers=$(echo "$blocked_by" | tr ',[]' ' ' | tr -s ' ')
|
|
117
|
+
|
|
118
|
+
for blocker_id in $blockers; do
|
|
119
|
+
[[ -z "$blocker_id" ]] && continue
|
|
120
|
+
if ! _is_task_resolved "$blocker_id"; then
|
|
121
|
+
if [[ -n "$unresolved_list" ]]; then
|
|
122
|
+
unresolved_list="$unresolved_list, $blocker_id"
|
|
123
|
+
else
|
|
124
|
+
unresolved_list="$blocker_id"
|
|
125
|
+
fi
|
|
126
|
+
fi
|
|
127
|
+
done
|
|
128
|
+
|
|
129
|
+
if [[ -n "$unresolved_list" ]]; then
|
|
130
|
+
if [[ "$has_blocked" == "false" ]]; then
|
|
131
|
+
echo "blocked_tasks:"
|
|
132
|
+
has_blocked=true
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
local task_id title
|
|
136
|
+
task_id=$(node "$FRONTMATTER_JS" "$task_file" "id" 2>/dev/null | sed -n 's/^id=//p')
|
|
137
|
+
title=$(node "$FRONTMATTER_JS" "$task_file" "title" 2>/dev/null | sed -n 's/^title=//p')
|
|
138
|
+
task_id="${task_id:-$(basename "$task_file" .md)}"
|
|
139
|
+
title="${title:-Untitled}"
|
|
140
|
+
|
|
141
|
+
printf ' - id: %s\n' "$task_id"
|
|
142
|
+
printf ' title: "%s"\n' "$title"
|
|
143
|
+
printf ' waiting_on: "%s"\n' "$unresolved_list"
|
|
144
|
+
fi
|
|
145
|
+
done
|
|
146
|
+
}
|
|
@@ -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
|
+
}
|