@sugar-crash-studios/vibe-forge 0.4.0

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 (201) hide show
  1. package/.claude/commands/clear-attention.md +63 -0
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +102 -0
  4. package/.claude/commands/forge.md +171 -0
  5. package/.claude/commands/need-help.md +77 -0
  6. package/.claude/commands/update-status.md +64 -0
  7. package/.claude/commands/worker-loop.md +106 -0
  8. package/.claude/hooks/worker-loop.js +198 -0
  9. package/.claude/scripts/setup-worker-loop.sh +45 -0
  10. package/.claude/settings.local.json +46 -0
  11. package/LICENSE +21 -0
  12. package/README.md +238 -0
  13. package/agents/aegis/personality.md +294 -0
  14. package/agents/anvil/personality.md +276 -0
  15. package/agents/architect/personality.md +258 -0
  16. package/agents/crucible/personality.md +360 -0
  17. package/agents/ember/personality.md +291 -0
  18. package/agents/forge-master/capabilities.md +144 -0
  19. package/agents/forge-master/context-template.md +128 -0
  20. package/agents/forge-master/personality.md +138 -0
  21. package/agents/furnace/personality.md +340 -0
  22. package/agents/herald/personality.md +247 -0
  23. package/agents/loki/personality.md +108 -0
  24. package/agents/oracle/personality.md +283 -0
  25. package/agents/pixel/personality.md +113 -0
  26. package/agents/planning-hub/personality.md +320 -0
  27. package/agents/scribe/personality.md +251 -0
  28. package/agents/temper/personality.md +218 -0
  29. package/bin/cli.js +375 -0
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +483 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/frontend/index.html +13 -0
  34. package/bin/dashboard/frontend/package.json +16 -0
  35. package/bin/dashboard/frontend/src/App.svelte +222 -0
  36. package/bin/dashboard/frontend/src/app.css +1777 -0
  37. package/bin/dashboard/frontend/src/lib/components/AgentCard.svelte +60 -0
  38. package/bin/dashboard/frontend/src/lib/components/AgentsPanel.svelte +57 -0
  39. package/bin/dashboard/frontend/src/lib/components/DispatchModal.svelte +180 -0
  40. package/bin/dashboard/frontend/src/lib/components/Footer.svelte +33 -0
  41. package/bin/dashboard/frontend/src/lib/components/Header.svelte +84 -0
  42. package/bin/dashboard/frontend/src/lib/components/IssueCard.svelte +33 -0
  43. package/bin/dashboard/frontend/src/lib/components/IssuesPanel.svelte +73 -0
  44. package/bin/dashboard/frontend/src/lib/components/KeyboardShortcutsModal.svelte +108 -0
  45. package/bin/dashboard/frontend/src/lib/components/MobileTabs.svelte +52 -0
  46. package/bin/dashboard/frontend/src/lib/components/NotificationCard.svelte +60 -0
  47. package/bin/dashboard/frontend/src/lib/components/NotificationsPanel.svelte +44 -0
  48. package/bin/dashboard/frontend/src/lib/components/TaskCard.svelte +63 -0
  49. package/bin/dashboard/frontend/src/lib/components/TasksPanel.svelte +82 -0
  50. package/bin/dashboard/frontend/src/lib/components/Toast.svelte +45 -0
  51. package/bin/dashboard/frontend/src/lib/stores/agents.js +34 -0
  52. package/bin/dashboard/frontend/src/lib/stores/issues.js +54 -0
  53. package/bin/dashboard/frontend/src/lib/stores/notifications.js +48 -0
  54. package/bin/dashboard/frontend/src/lib/stores/tasks.js +63 -0
  55. package/bin/dashboard/frontend/src/lib/stores/theme.js +33 -0
  56. package/bin/dashboard/frontend/src/lib/stores/toast.js +35 -0
  57. package/bin/dashboard/frontend/src/lib/stores/ui.js +25 -0
  58. package/bin/dashboard/frontend/src/lib/stores/voice.js +275 -0
  59. package/bin/dashboard/frontend/src/lib/stores/websocket.js +295 -0
  60. package/bin/dashboard/frontend/src/lib/utils/api.js +101 -0
  61. package/bin/dashboard/frontend/src/lib/utils/formatters.js +54 -0
  62. package/bin/dashboard/frontend/src/main.js +9 -0
  63. package/bin/dashboard/frontend/svelte.config.js +5 -0
  64. package/bin/dashboard/frontend/vite.config.js +20 -0
  65. package/bin/dashboard/public/assets/index-DnfVj9Ce.css +1 -0
  66. package/bin/dashboard/public/assets/index-Ze5h0kXQ.js +2 -0
  67. package/bin/dashboard/public/index.html +14 -0
  68. package/bin/dashboard/server.js +566 -0
  69. package/bin/forge-daemon.sh +463 -0
  70. package/bin/forge-setup.sh +645 -0
  71. package/bin/forge-spawn.sh +164 -0
  72. package/bin/forge.cmd +83 -0
  73. package/bin/forge.sh +533 -0
  74. package/bin/lib/agents.sh +177 -0
  75. package/bin/lib/colors.sh +44 -0
  76. package/bin/lib/config.sh +347 -0
  77. package/bin/lib/constants.sh +241 -0
  78. package/bin/lib/daemon/display.sh +128 -0
  79. package/bin/lib/daemon/notifications.sh +263 -0
  80. package/bin/lib/daemon/routing.sh +77 -0
  81. package/bin/lib/daemon/state.sh +115 -0
  82. package/bin/lib/daemon/sync.sh +95 -0
  83. package/bin/lib/database.sh +310 -0
  84. package/bin/lib/heimdall-setup.js +113 -0
  85. package/bin/lib/heimdall.js +265 -0
  86. package/bin/lib/json.sh +264 -0
  87. package/bin/lib/terminal.js +451 -0
  88. package/bin/lib/util.sh +126 -0
  89. package/bin/lib/vcs.js +349 -0
  90. package/config/agent-manifest.yaml +203 -0
  91. package/config/agents.json +168 -0
  92. package/config/task-template.md +159 -0
  93. package/config/task-types.yaml +106 -0
  94. package/context/agent-status/aegis.json +7 -0
  95. package/context/agent-status/anvil.json +7 -0
  96. package/context/agent-status/architect.json +7 -0
  97. package/context/agent-status/crucible.json +7 -0
  98. package/context/agent-status/ember.json +7 -0
  99. package/context/agent-status/furnace.json +7 -0
  100. package/context/agent-status/loki.json +7 -0
  101. package/context/agent-status/oracle.json +7 -0
  102. package/context/agent-status/pixel.json +7 -0
  103. package/context/agent-status/planning-hub.json +7 -0
  104. package/context/agent-status/scribe.json +7 -0
  105. package/context/agent-status/temper.json +7 -0
  106. package/context/feature-brainstorm.md +426 -0
  107. package/context/forge-state.yaml +19 -0
  108. package/context/modern-conventions.md +129 -0
  109. package/context/project-context-template.md +122 -0
  110. package/context/project-context.md +122 -0
  111. package/docs/TODO.md +150 -0
  112. package/docs/agents.md +409 -0
  113. package/docs/architecture/decisions/ADR-001-daemon-modularization.md +122 -0
  114. package/docs/architecture/vibe-lab-integration.md +684 -0
  115. package/docs/architecture.md +194 -0
  116. package/docs/bmad-gap-analysis-2026-03-31.md +444 -0
  117. package/docs/cleanup-workflow.md +329 -0
  118. package/docs/commands.md +451 -0
  119. package/docs/dashboard-mockup.html +989 -0
  120. package/docs/getting-started.md +261 -0
  121. package/docs/integration/forge-ownership-policy.md +112 -0
  122. package/docs/npm-publishing.md +132 -0
  123. package/docs/roadmap-2026.md +519 -0
  124. package/docs/security.md +144 -0
  125. package/docs/wireframes/dashboard-mvp.md +1164 -0
  126. package/docs/workflows/README.md +32 -0
  127. package/docs/workflows/azure-devops.md +108 -0
  128. package/docs/workflows/bitbucket.md +104 -0
  129. package/docs/workflows/git-only.md +130 -0
  130. package/docs/workflows/gitea.md +168 -0
  131. package/docs/workflows/github.md +103 -0
  132. package/docs/workflows/gitlab.md +105 -0
  133. package/docs/workflows.md +454 -0
  134. package/package.json +73 -0
  135. package/tasks/completed/ARCH-001-duplicate-agent-config.md +121 -0
  136. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +88 -0
  137. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +77 -0
  138. package/tasks/completed/ARCH-009-test-organization.md +78 -0
  139. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +94 -0
  140. package/tasks/completed/ARCH-012-tmp-files-in-root.md +71 -0
  141. package/tasks/completed/ARCH-013-exit-code-constants.md +65 -0
  142. package/tasks/completed/ARCH-014-sed-incompatibility.md +96 -0
  143. package/tasks/completed/ARCH-015-docs-todo-tracking.md +83 -0
  144. package/tasks/completed/BUG-dash-001-tasks-filter-error.md +31 -0
  145. package/tasks/completed/BUG-dash-002-agents-unknown.md +41 -0
  146. package/tasks/completed/CLEAN-001.md +38 -0
  147. package/tasks/completed/CLEAN-002.md +43 -0
  148. package/tasks/completed/CLEAN-003.md +47 -0
  149. package/tasks/completed/CLEAN-004.md +56 -0
  150. package/tasks/completed/CLEAN-005.md +75 -0
  151. package/tasks/completed/CLEAN-006.md +47 -0
  152. package/tasks/completed/CLEAN-007.md +34 -0
  153. package/tasks/completed/CLEAN-008.md +49 -0
  154. package/tasks/completed/CLEAN-012.md +58 -0
  155. package/tasks/completed/CLEAN-013.md +45 -0
  156. package/tasks/completed/FEATURE-001a-dashboard-wireframes.md +162 -0
  157. package/tasks/completed/IMPL-007a-daemon-notifications-module.md +82 -0
  158. package/tasks/completed/IMPL-007b-daemon-sync-module.md +71 -0
  159. package/tasks/completed/IMPL-007c-daemon-state-module.md +80 -0
  160. package/tasks/completed/IMPL-007d-daemon-routing-module.md +77 -0
  161. package/tasks/completed/IMPL-007e-daemon-display-module.md +77 -0
  162. package/tasks/completed/IMPL-007f-daemon-integration.md +124 -0
  163. package/tasks/completed/PLAT-1-heimdall.md +420 -0
  164. package/tasks/completed/SEC-001-sql-injection-fix.md +58 -0
  165. package/tasks/completed/SEC-002-notification-injection-fix.md +45 -0
  166. package/tasks/completed/SEC-003-eval-injection-fix.md +54 -0
  167. package/tasks/completed/SEC-004-pid-race-condition-fix.md +49 -0
  168. package/tasks/completed/SEC-005-worker-loop-path-fix.md +51 -0
  169. package/tasks/completed/SEC-006-eval-agent-names.md +55 -0
  170. package/tasks/completed/SEC-007-spawn-escaping.md +67 -0
  171. package/tasks/completed/TASK-DASH-001-server-infrastructure.md +185 -0
  172. package/tasks/completed/TASK-anvil-001-dashboard-frontend.md +133 -0
  173. package/tasks/completed/review-bmad-aegis.md +89 -0
  174. package/tasks/completed/review-bmad-anvil.md +80 -0
  175. package/tasks/completed/review-bmad-crucible.md +81 -0
  176. package/tasks/completed/review-bmad-ember.md +90 -0
  177. package/tasks/completed/review-bmad-furnace.md +79 -0
  178. package/tasks/completed/review-bmad-pixel.md +82 -0
  179. package/tasks/completed/review-bmad-scribe.md +92 -0
  180. package/tasks/completed/review-bmad-sentinel.md +83 -0
  181. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +72 -0
  182. package/tasks/pending/ARCH-005-missing-src-directory.md +95 -0
  183. package/tasks/pending/ARCH-006-task-template-location.md +64 -0
  184. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +81 -0
  185. package/tasks/pending/ARCH-010-missing-index-files.md +84 -0
  186. package/tasks/pending/CLEAN-009.md +31 -0
  187. package/tasks/pending/CLEAN-010.md +30 -0
  188. package/tasks/pending/CLEAN-011.md +30 -0
  189. package/tasks/pending/CLEAN-014.md +32 -0
  190. package/tasks/pending/DESIGN-dash-001-layout-review.md +45 -0
  191. package/tasks/pending/FEATURE-001-dashboard-mvp.md +268 -0
  192. package/tasks/review/ARCH-007-daemon-monolith.md +162 -0
  193. package/tasks/review/bmad-review-aegis.md +349 -0
  194. package/tasks/review/bmad-review-anvil.md +259 -0
  195. package/tasks/review/bmad-review-crucible.md +277 -0
  196. package/tasks/review/bmad-review-ember.md +307 -0
  197. package/tasks/review/bmad-review-furnace.md +285 -0
  198. package/tasks/review/bmad-review-pixel.md +329 -0
  199. package/tasks/review/bmad-review-scribe.md +361 -0
  200. package/tasks/review/bmad-review-sentinel.md +242 -0
  201. package/tasks/review/task-001.md +78 -0
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Vibe Forge - SQLite Database Operations
4
+ # Handles daemon state persistence and agent status aggregation
5
+ #
6
+
7
+ # Database file location (set after FORGE_ROOT is known)
8
+ FORGE_DB=""
9
+
10
+ # =============================================================================
11
+ # Security Functions
12
+ # =============================================================================
13
+
14
+ # Escape a string for safe use in SQLite queries
15
+ # Escapes single quotes by doubling them (SQL standard)
16
+ # Also removes null bytes and other dangerous characters
17
+ db_escape() {
18
+ local input="$1"
19
+ # Remove null bytes
20
+ input="${input//$'\0'/}"
21
+ # Escape single quotes by doubling them
22
+ input="${input//\'/\'\'}"
23
+ # Remove backslashes that could escape quotes
24
+ input="${input//\\/}"
25
+ echo "$input"
26
+ }
27
+
28
+ # Validate that input contains only safe characters for identifiers
29
+ # Returns 0 if safe, 1 if unsafe
30
+ db_validate_identifier() {
31
+ local input="$1"
32
+ # Allow only alphanumeric, underscore, hyphen
33
+ if [[ "$input" =~ ^[a-zA-Z0-9_-]+$ ]]; then
34
+ return 0
35
+ fi
36
+ return 1
37
+ }
38
+
39
+ # =============================================================================
40
+ # Database Initialization
41
+ # =============================================================================
42
+
43
+ # Require FORGE_DB to be set before database operations
44
+ # Call this at the start of database functions that need the path
45
+ # Usage: db_require_init || return 1
46
+ db_require_init() {
47
+ if [[ -z "$FORGE_DB" ]]; then
48
+ echo "Error: FORGE_DB not set. Set FORGE_DB path before calling database functions." >&2
49
+ return 1
50
+ fi
51
+ return 0
52
+ }
53
+
54
+ # Initialize the database with schema
55
+ # Requires: FORGE_DB must be set before calling
56
+ db_init() {
57
+ db_require_init || return 1
58
+ local db_dir
59
+ db_dir=$(dirname "$FORGE_DB")
60
+
61
+ # Create directory if needed
62
+ mkdir -p "$db_dir"
63
+ chmod 700 "$db_dir"
64
+
65
+ # Enable WAL mode for concurrent reads without blocking writes.
66
+ # WAL + NORMAL synchronous is the recommended pairing for local tooling:
67
+ # faster than DELETE+FULL while still durable against OS crashes.
68
+ sqlite3 "$FORGE_DB" "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
69
+
70
+ # Create tables if they don't exist
71
+ sqlite3 "$FORGE_DB" <<'SQL'
72
+ -- Daemon configuration (single row)
73
+ CREATE TABLE IF NOT EXISTS daemon_config (
74
+ id INTEGER PRIMARY KEY CHECK (id = 1),
75
+ state TEXT DEFAULT 'idle',
76
+ poll_interval_ms INTEGER DEFAULT 5000,
77
+ last_activity_at TEXT,
78
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
79
+ );
80
+
81
+ -- Polling presets
82
+ CREATE TABLE IF NOT EXISTS poll_presets (
83
+ state TEXT PRIMARY KEY,
84
+ interval_ms INTEGER NOT NULL
85
+ );
86
+
87
+ -- Insert default presets if not exist
88
+ INSERT OR IGNORE INTO poll_presets (state, interval_ms) VALUES
89
+ ('active', 5000),
90
+ ('idle', 30000),
91
+ ('stopped', 60000);
92
+
93
+ -- Initialize daemon_config if empty
94
+ INSERT OR IGNORE INTO daemon_config (id, state, poll_interval_ms)
95
+ VALUES (1, 'idle', 30000);
96
+
97
+ -- Agent status (aggregated from JSON files)
98
+ CREATE TABLE IF NOT EXISTS agent_status (
99
+ agent TEXT PRIMARY KEY,
100
+ status TEXT DEFAULT 'unknown',
101
+ task TEXT,
102
+ message TEXT,
103
+ updated_at TEXT,
104
+ file_mtime INTEGER DEFAULT 0
105
+ );
106
+
107
+ -- Status history for metrics (optional, pruned periodically)
108
+ CREATE TABLE IF NOT EXISTS status_history (
109
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
110
+ agent TEXT NOT NULL,
111
+ status TEXT NOT NULL,
112
+ task TEXT,
113
+ recorded_at TEXT DEFAULT CURRENT_TIMESTAMP
114
+ );
115
+
116
+ -- Index for history queries
117
+ CREATE INDEX IF NOT EXISTS idx_history_agent ON status_history(agent);
118
+ CREATE INDEX IF NOT EXISTS idx_history_recorded ON status_history(recorded_at);
119
+ SQL
120
+
121
+ # Set secure permissions on database file
122
+ chmod 600 "$FORGE_DB" 2>/dev/null || true
123
+ }
124
+
125
+ # =============================================================================
126
+ # Daemon State Operations
127
+ # =============================================================================
128
+
129
+ # Get current daemon state (active/idle/stopped)
130
+ db_get_daemon_state() {
131
+ db_require_init || return 1
132
+ sqlite3 "$FORGE_DB" "SELECT state FROM daemon_config WHERE id = 1;"
133
+ }
134
+
135
+ # Set daemon state and update poll interval accordingly
136
+ db_set_daemon_state() {
137
+ db_require_init || return 1
138
+ local new_state="$1"
139
+ local interval
140
+
141
+ # SECURITY: Validate state is a known value
142
+ case "$new_state" in
143
+ active|idle|stopped) ;;
144
+ *)
145
+ echo "Invalid daemon state: $new_state" >&2
146
+ return 1
147
+ ;;
148
+ esac
149
+
150
+ # Get interval for this state from presets (safe - validated above)
151
+ interval=$(sqlite3 "$FORGE_DB" "SELECT interval_ms FROM poll_presets WHERE state = '$new_state';")
152
+ interval="${interval:-30000}"
153
+
154
+ sqlite3 "$FORGE_DB" <<SQL
155
+ UPDATE daemon_config
156
+ SET state = '$new_state',
157
+ poll_interval_ms = $interval,
158
+ updated_at = datetime('now')
159
+ WHERE id = 1;
160
+ SQL
161
+ }
162
+
163
+ # Get current poll interval in milliseconds
164
+ db_get_poll_interval_ms() {
165
+ sqlite3 "$FORGE_DB" "SELECT poll_interval_ms FROM daemon_config WHERE id = 1;"
166
+ }
167
+
168
+ # Update last activity timestamp
169
+ db_touch_activity() {
170
+ sqlite3 "$FORGE_DB" <<SQL
171
+ UPDATE daemon_config
172
+ SET last_activity_at = datetime('now'),
173
+ updated_at = datetime('now')
174
+ WHERE id = 1;
175
+ SQL
176
+ }
177
+
178
+ # =============================================================================
179
+ # Agent Status Operations
180
+ # =============================================================================
181
+
182
+ # Upsert agent status (from JSON file data)
183
+ db_upsert_agent_status() {
184
+ db_require_init || return 1
185
+ local agent="$1"
186
+ local status="$2"
187
+ local task="$3"
188
+ local message="$4"
189
+ local updated_at="$5"
190
+ local file_mtime="$6"
191
+
192
+ # SECURITY: Escape all string inputs to prevent SQL injection
193
+ agent=$(db_escape "$agent")
194
+ status=$(db_escape "$status")
195
+ task=$(db_escape "$task")
196
+ message=$(db_escape "$message")
197
+ updated_at=$(db_escape "$updated_at")
198
+
199
+ # Validate file_mtime is numeric
200
+ if ! [[ "$file_mtime" =~ ^[0-9]+$ ]]; then
201
+ file_mtime=0
202
+ fi
203
+
204
+ sqlite3 "$FORGE_DB" <<SQL
205
+ INSERT INTO agent_status (agent, status, task, message, updated_at, file_mtime)
206
+ VALUES ('$agent', '$status', '$task', '$message', '$updated_at', $file_mtime)
207
+ ON CONFLICT(agent) DO UPDATE SET
208
+ status = excluded.status,
209
+ task = excluded.task,
210
+ message = excluded.message,
211
+ updated_at = excluded.updated_at,
212
+ file_mtime = excluded.file_mtime;
213
+ SQL
214
+ }
215
+
216
+ # Get stored mtime for an agent's status file
217
+ db_get_agent_mtime() {
218
+ local agent="$1"
219
+ # SECURITY: Escape agent name to prevent SQL injection
220
+ agent=$(db_escape "$agent")
221
+ sqlite3 "$FORGE_DB" "SELECT file_mtime FROM agent_status WHERE agent = '$agent';" 2>/dev/null || echo "0"
222
+ }
223
+
224
+ # Get all agent statuses
225
+ db_get_all_agent_statuses() {
226
+ sqlite3 -separator '|' "$FORGE_DB" <<SQL
227
+ SELECT agent, status, task, message, updated_at
228
+ FROM agent_status
229
+ ORDER BY agent;
230
+ SQL
231
+ }
232
+
233
+ # Get agents with specific status
234
+ db_get_agents_by_status() {
235
+ local status="$1"
236
+ # SECURITY: Escape status to prevent SQL injection
237
+ status=$(db_escape "$status")
238
+ sqlite3 "$FORGE_DB" "SELECT agent FROM agent_status WHERE status = '$status';"
239
+ }
240
+
241
+ # Count active workers (status = 'working')
242
+ db_count_active_workers() {
243
+ sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM agent_status WHERE status = 'working';"
244
+ }
245
+
246
+ # Delete stale agent status entries
247
+ db_cleanup_stale_agents() {
248
+ local minutes="${1:-30}"
249
+ # SECURITY: Validate minutes is numeric to prevent SQL injection
250
+ if ! [[ "$minutes" =~ ^[0-9]+$ ]]; then
251
+ minutes=30
252
+ fi
253
+ sqlite3 "$FORGE_DB" <<SQL
254
+ DELETE FROM agent_status
255
+ WHERE updated_at < datetime('now', '-$minutes minutes');
256
+ SQL
257
+ }
258
+
259
+ # =============================================================================
260
+ # History Operations (for future metrics)
261
+ # =============================================================================
262
+
263
+ # Record status change in history
264
+ db_record_status_history() {
265
+ local agent="$1"
266
+ local status="$2"
267
+ local task="$3"
268
+
269
+ # SECURITY: Escape all inputs to prevent SQL injection
270
+ agent=$(db_escape "$agent")
271
+ status=$(db_escape "$status")
272
+ task=$(db_escape "$task")
273
+
274
+ sqlite3 "$FORGE_DB" <<SQL
275
+ INSERT INTO status_history (agent, status, task)
276
+ VALUES ('$agent', '$status', '$task');
277
+ SQL
278
+ }
279
+
280
+ # Prune old history entries (keep last N days)
281
+ db_prune_history() {
282
+ local days="${1:-7}"
283
+ # SECURITY: Validate days is numeric to prevent SQL injection
284
+ if ! [[ "$days" =~ ^[0-9]+$ ]]; then
285
+ days=7
286
+ fi
287
+ sqlite3 "$FORGE_DB" <<SQL
288
+ DELETE FROM status_history
289
+ WHERE recorded_at < datetime('now', '-$days days');
290
+ SQL
291
+ }
292
+
293
+ # =============================================================================
294
+ # Utility Functions
295
+ # =============================================================================
296
+
297
+ # Check if database exists and is valid
298
+ db_exists() {
299
+ [[ -n "$FORGE_DB" ]] && [[ -f "$FORGE_DB" ]] && sqlite3 "$FORGE_DB" "SELECT 1 FROM daemon_config LIMIT 1;" &>/dev/null
300
+ }
301
+
302
+ # Get database stats for debugging
303
+ db_stats() {
304
+ db_require_init || return 1
305
+ echo "Database: $FORGE_DB"
306
+ echo "Agent count: $(sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM agent_status;")"
307
+ echo "History entries: $(sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM status_history;")"
308
+ echo "Current state: $(db_get_daemon_state)"
309
+ echo "Poll interval: $(db_get_poll_interval_ms)ms"
310
+ }
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Heimdall Setup -- writes .claude/settings.local.json into a worker's
4
+ * working directory to register Heimdall as a PreToolUse hook.
5
+ *
6
+ * Called by the forge daemon at inbox-write time, before the worker
7
+ * picks up a lab task.
8
+ *
9
+ * Uses a merge strategy: if settings.local.json already exists, the
10
+ * Heimdall hooks are merged into the existing PreToolUse array rather
11
+ * than overwriting the file.
12
+ */
13
+
14
+ 'use strict'
15
+
16
+ const fs = require('fs')
17
+ const path = require('path')
18
+
19
+ // Absolute path to heimdall.js -- resolvable from any working directory
20
+ const HEIMDALL_PATH = path.resolve(__dirname, 'heimdall.js').replace(/\\/g, '/')
21
+
22
+ const HEIMDALL_HOOKS = ['Bash', 'Write', 'Edit'].map(matcher => ({
23
+ matcher,
24
+ hooks: [{ type: 'command', command: `node "${HEIMDALL_PATH}"` }],
25
+ }))
26
+
27
+ /**
28
+ * writeHeimdallHooks(worktreePath)
29
+ *
30
+ * Writes or merges Heimdall PreToolUse hooks into:
31
+ * <worktreePath>/.claude/settings.local.json
32
+ *
33
+ * Safe to call multiple times -- idempotent.
34
+ *
35
+ * @param {string} worktreePath Absolute path to the worker's worktree root
36
+ */
37
+ function writeHeimdallHooks(worktreePath) {
38
+ const claudeDir = path.join(worktreePath, '.claude')
39
+ const settingsPath = path.join(claudeDir, 'settings.local.json')
40
+
41
+ // Ensure .claude/ exists
42
+ if (!fs.existsSync(claudeDir)) {
43
+ fs.mkdirSync(claudeDir, { recursive: true })
44
+ }
45
+
46
+ // Read existing settings if present
47
+ let existing = {}
48
+ if (fs.existsSync(settingsPath)) {
49
+ try {
50
+ existing = JSON.parse(fs.readFileSync(settingsPath, 'utf8'))
51
+ } catch (_) {
52
+ // Corrupt file -- start fresh
53
+ existing = {}
54
+ }
55
+ }
56
+
57
+ // Ensure hooks structure exists
58
+ if (!existing.hooks) existing.hooks = {}
59
+ if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = []
60
+
61
+ // Merge: add Heimdall hook entries for matchers not already registered
62
+ const existingMatchers = new Set(
63
+ existing.hooks.PreToolUse.map(h => h.matcher)
64
+ )
65
+
66
+ for (const heimdallHook of HEIMDALL_HOOKS) {
67
+ if (!existingMatchers.has(heimdallHook.matcher)) {
68
+ existing.hooks.PreToolUse.push(heimdallHook)
69
+ }
70
+ }
71
+
72
+ fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n')
73
+ }
74
+
75
+ /**
76
+ * writeContextFile(worktreePath, context)
77
+ *
78
+ * Writes the Heimdall context file to the worktree root so Heimdall
79
+ * can read per-task policy on every invocation.
80
+ *
81
+ * @param {string} worktreePath Absolute path to the worker's worktree root
82
+ * @param {object} context Context object matching the schema below
83
+ *
84
+ * Context schema:
85
+ * {
86
+ * story_id: string -- lab story ID (e.g. "FORGE-3")
87
+ * agent: string -- worker name (e.g. "anvil")
88
+ * worktree_path: string -- absolute path to worktree (same as worktreePath)
89
+ * assigned_branch: string -- git branch for this story
90
+ * handoff_dir: string -- absolute path to _vibe-chain-output/handoffs/
91
+ * escalation_dir: string -- absolute path to worker-inbox/<agent>/ dir
92
+ * audit_log: string -- absolute path to heimdall-audit.log
93
+ * has_db_migration: boolean
94
+ * has_api_changes: boolean
95
+ * allowed_paths: string[] -- absolute paths the worker may read/write
96
+ * }
97
+ */
98
+ function writeContextFile(worktreePath, context) {
99
+ const contextPath = path.join(worktreePath, '.context.json')
100
+ fs.writeFileSync(contextPath, JSON.stringify(context, null, 2) + '\n')
101
+ }
102
+
103
+ /**
104
+ * setup(worktreePath, context)
105
+ *
106
+ * Convenience function: writes both hooks and context file in one call.
107
+ */
108
+ function setup(worktreePath, context) {
109
+ writeHeimdallHooks(worktreePath)
110
+ writeContextFile(worktreePath, context)
111
+ }
112
+
113
+ module.exports = { setup, writeHeimdallHooks, writeContextFile, HEIMDALL_PATH }
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Heimdall -- Forge worker pre-tool hook interceptor (PLAT-1)
4
+ *
5
+ * Guards the Bifrost: intercepts every tool call before execution,
6
+ * checks it against per-task policy, and blocks or allows.
7
+ *
8
+ * Registered as a PreToolUse hook via .claude/settings.local.json
9
+ * written by the forge daemon at worker startup.
10
+ *
11
+ * Exit codes:
12
+ * 0 -- allow (optionally with audit log entry)
13
+ * 2 -- block (explanation written to stdout, fed back to model)
14
+ *
15
+ * Context file (.context.json in process.cwd()) is written by the forge
16
+ * daemon alongside each inbox task. If absent, Heimdall exits 0 immediately
17
+ * -- forge-native tasks are not restricted.
18
+ */
19
+
20
+ 'use strict'
21
+
22
+ const fs = require('fs')
23
+ const path = require('path')
24
+
25
+ // ── Input ─────────────────────────────────────────────────────────────────────
26
+
27
+ let input
28
+ try {
29
+ input = JSON.parse(fs.readFileSync(0, 'utf8'))
30
+ } catch (e) {
31
+ // Malformed input -- allow and exit rather than blocking the worker
32
+ process.exit(0)
33
+ }
34
+
35
+ const { tool_name, tool_input } = input
36
+
37
+ // ── Context file ──────────────────────────────────────────────────────────────
38
+
39
+ const contextPath = path.join(process.cwd(), '.context.json')
40
+
41
+ // No context file = forge-native task. Heimdall does not restrict forge-native work.
42
+ if (!fs.existsSync(contextPath)) {
43
+ process.exit(0)
44
+ }
45
+
46
+ let ctx
47
+ try {
48
+ ctx = JSON.parse(fs.readFileSync(contextPath, 'utf8'))
49
+ } catch (e) {
50
+ // Unreadable context -- fail open to avoid blocking forge-native work
51
+ process.exit(0)
52
+ }
53
+
54
+ const {
55
+ story_id,
56
+ agent,
57
+ worktree_path,
58
+ has_db_migration,
59
+ allowed_paths,
60
+ escalation_dir,
61
+ audit_log: ctxAuditLog,
62
+ } = ctx
63
+
64
+ // ── Logging ───────────────────────────────────────────────────────────────────
65
+
66
+ const AUDIT_LOG = ctxAuditLog
67
+ || path.join(process.cwd(), '_vibe-chain-output', 'heimdall-audit.log')
68
+
69
+ const MAX_VIOLATIONS = parseInt(process.env.HEIMDALL_MAX_VIOLATIONS || '3', 10)
70
+
71
+ // Violation tracking lives in the worktree root (process.cwd())
72
+ const VIOLATION_FILE = path.join(process.cwd(), `.${story_id}.violations`)
73
+
74
+ // Escalation signal written to the inbox agent dir so the daemon detects it
75
+ const ESCALATION_DIR = escalation_dir || process.cwd()
76
+
77
+ function timestamp() {
78
+ return new Date().toISOString()
79
+ }
80
+
81
+ function audit(level, message) {
82
+ const line = `[${timestamp()}] HEIMDALL ${level} ${agent}/${story_id}: ${message}\n`
83
+ try {
84
+ const dir = path.dirname(AUDIT_LOG)
85
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
86
+ fs.appendFileSync(AUDIT_LOG, line)
87
+ } catch (_) {
88
+ // Audit log write failure must never block or crash the hook
89
+ }
90
+ }
91
+
92
+ function block(reason) {
93
+ audit('BLOCKED', reason)
94
+
95
+ // Track violations
96
+ let violations = 0
97
+ try {
98
+ if (fs.existsSync(VIOLATION_FILE)) {
99
+ violations = parseInt(fs.readFileSync(VIOLATION_FILE, 'utf8').trim(), 10) || 0
100
+ }
101
+ } catch (_) {}
102
+
103
+ violations++
104
+
105
+ try {
106
+ fs.writeFileSync(VIOLATION_FILE, String(violations))
107
+ } catch (_) {}
108
+
109
+ if (violations >= MAX_VIOLATIONS) {
110
+ // Sound the Gjallarhorn
111
+ const escalationFile = path.join(ESCALATION_DIR, `${story_id}.escalation`)
112
+ try {
113
+ fs.writeFileSync(escalationFile, JSON.stringify({
114
+ story_id,
115
+ agent,
116
+ violations,
117
+ last_reason: reason,
118
+ timestamp: timestamp(),
119
+ }, null, 2))
120
+ } catch (_) {}
121
+
122
+ audit('SOUNDED', `${violations} violations -- escalating to human review`)
123
+
124
+ console.log(
125
+ `[HEIMDALL] Action blocked: ${reason}\n` +
126
+ `[HEIMDALL] GJALLARHORN SOUNDED: ${violations} violations recorded.\n` +
127
+ `[HEIMDALL] Story ${story_id} has been escalated to human review. Stop working on this story.`
128
+ )
129
+ } else {
130
+ console.log(
131
+ `[HEIMDALL] Action blocked: ${reason}\n` +
132
+ `[HEIMDALL] Violation ${violations}/${MAX_VIOLATIONS}. Self-correct and continue.`
133
+ )
134
+ }
135
+
136
+ process.exit(2)
137
+ }
138
+
139
+ function allow(note) {
140
+ if (note) audit('ALLOWED', note)
141
+ process.exit(0)
142
+ }
143
+
144
+ // ── Path helpers ──────────────────────────────────────────────────────────────
145
+
146
+ function isOutsideAllowedPaths(targetPath) {
147
+ if (!targetPath) return false
148
+ try {
149
+ const resolved = path.resolve(targetPath)
150
+ return !allowed_paths.some(p => resolved.startsWith(path.resolve(p)))
151
+ } catch (_) {
152
+ return true // Unresolvable path -- treat as outside
153
+ }
154
+ }
155
+
156
+ // ── Bash tool checks ──────────────────────────────────────────────────────────
157
+
158
+ if (tool_name === 'Bash') {
159
+ const cmd = (tool_input && tool_input.command || '').trim()
160
+
161
+ // Destructive rm -- check if target escapes worktree
162
+ if (/rm\s+-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r/.test(cmd) && /\brm\b/.test(cmd)) {
163
+ // Extract everything after the flags as the target
164
+ const rmMatch = cmd.match(/rm\s+(?:-\S+\s+)(.+)/)
165
+ const rmTarget = rmMatch ? rmMatch[1].trim().replace(/^['"]|['"]$/g, '') : ''
166
+
167
+ const isRootTarget = /^(\/|~)/.test(rmTarget) || rmTarget === '' || rmTarget.includes('..')
168
+ const isOutside = !rmTarget || isRootTarget || isOutsideAllowedPaths(rmTarget)
169
+
170
+ if (isOutside) {
171
+ block(`destructive rm outside worktree: ${cmd}`)
172
+ }
173
+ }
174
+
175
+ // Credential access
176
+ if (/\.(env|pem|key|cert)\b/.test(cmd) && !/\.env\.(example|sample|template)/.test(cmd)) {
177
+ block(`credential file access: ${cmd}`)
178
+ }
179
+ if (/~\/\.(ssh|aws|gnupg)\b/.test(cmd)) {
180
+ block(`credential directory access: ${cmd}`)
181
+ }
182
+ // Echoing or exporting secrets (but not reading them as part of build)
183
+ if (/(echo|printf|export)\s+.*\b(TOKEN|SECRET|PASSWORD|API_KEY)\s*=/.test(cmd)) {
184
+ block(`credential assignment in shell: ${cmd}`)
185
+ }
186
+
187
+ // Dangerous git operations
188
+ if (/git\s+push\b.*--force(-with-lease)?/.test(cmd)) {
189
+ block(`force push blocked: ${cmd}`)
190
+ }
191
+ if (/git\s+reset\s+--hard\s+HEAD~[2-9]/.test(cmd)) {
192
+ block(`multi-commit hard reset blocked: ${cmd}`)
193
+ }
194
+ if (/git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d|-[a-zA-Z]*d[a-zA-Z]*f/.test(cmd) && /\bgit\b/.test(cmd)) {
195
+ block(`git clean -fd blocked: ${cmd}`)
196
+ }
197
+ // Push to a non-origin remote (e.g. git push upstream)
198
+ if (/git\s+push\s+(?!origin\b)(\S+)/.test(cmd)) {
199
+ const remoteMatch = cmd.match(/git\s+push\s+(\S+)/)
200
+ if (remoteMatch && remoteMatch[1] !== 'origin' && !remoteMatch[1].startsWith('-')) {
201
+ block(`push to non-origin remote blocked: ${cmd}`)
202
+ }
203
+ }
204
+ // Direct push to main/master — all changes must go through PRs
205
+ if (/git\s+push\b/.test(cmd) && /\b(main|master)\b/.test(cmd)) {
206
+ block(`direct push to main/master blocked — create a feature branch and open a PR`)
207
+ }
208
+
209
+ // DB destructive operations without authorization
210
+ if (/(DROP\s+TABLE|TRUNCATE\s+TABLE)/i.test(cmd) && !has_db_migration) {
211
+ block(`DROP/TRUNCATE requires has_db_migration: true on the story`)
212
+ }
213
+ if (/DELETE\s+FROM\s+\S+\s*;?\s*$/i.test(cmd) && !/WHERE\s+/i.test(cmd)) {
214
+ block(`DELETE without WHERE clause blocked: ${cmd}`)
215
+ }
216
+
217
+ // High-risk but legitimate -- audit log only
218
+ if (/\b(npm|yarn|pnpm)\s+(install|add|ci)\b/.test(cmd)) {
219
+ allow(`dependency install: ${cmd}`)
220
+ }
221
+ if (/\b(pip|pip3)\s+install\b/.test(cmd)) {
222
+ allow(`pip install: ${cmd}`)
223
+ }
224
+ if (/\bcargo\s+(build|install)\b/.test(cmd)) {
225
+ allow(`cargo build: ${cmd}`)
226
+ }
227
+ if (/\b(curl|wget)\b/.test(cmd)) {
228
+ allow(`network request: ${cmd}`)
229
+ }
230
+ if (/git\s+commit\s+--amend/.test(cmd)) {
231
+ allow(`git commit --amend`)
232
+ }
233
+ }
234
+
235
+ // ── Write / Edit tool checks ──────────────────────────────────────────────────
236
+
237
+ if (tool_name === 'Write' || tool_name === 'Edit') {
238
+ const filePath = tool_input && (tool_input.file_path || tool_input.path) || ''
239
+
240
+ if (filePath) {
241
+ // Credential files
242
+ if (/\.(env|pem|key|cert)$/.test(filePath) && !/\.(example|sample|template)$/.test(filePath)) {
243
+ block(`write to credential file: ${filePath}`)
244
+ }
245
+
246
+ // Settings that could override Heimdall
247
+ if (/\.claude[/\\]settings(\.local)?\.json$/.test(filePath)) {
248
+ block(`write to .claude/settings blocked -- Heimdall config is daemon-managed`)
249
+ }
250
+
251
+ // Path escape check
252
+ if (isOutsideAllowedPaths(filePath)) {
253
+ block(`path escape: ${filePath} is outside allowed paths`)
254
+ }
255
+
256
+ // Pipeline output writes -- allowed but logged
257
+ if (filePath.includes('_vibe-chain-output')) {
258
+ allow(`pipeline output write: ${filePath}`)
259
+ }
260
+ }
261
+ }
262
+
263
+ // ── Default: allow ────────────────────────────────────────────────────────────
264
+
265
+ allow(null)