vibe-forge 0.4.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/.claude/commands/clear-attention.md +63 -63
  2. package/.claude/commands/compact-context.md +52 -0
  3. package/.claude/commands/configure-vcs.md +5 -5
  4. package/.claude/commands/forge.md +50 -3
  5. package/.claude/commands/need-help.md +77 -77
  6. package/.claude/commands/update-status.md +64 -64
  7. package/.claude/commands/worker-loop.md +106 -106
  8. package/.claude/hooks/worker-loop.js +37 -4
  9. package/.claude/scripts/setup-worker-loop.sh +45 -45
  10. package/.claude/settings.json +89 -0
  11. package/LICENSE +21 -21
  12. package/README.md +211 -232
  13. package/agents/aegis/personality.md +35 -1
  14. package/agents/anvil/personality.md +39 -1
  15. package/agents/architect/personality.md +26 -0
  16. package/agents/crucible/personality.md +54 -1
  17. package/agents/crucible-x/personality.md +210 -0
  18. package/agents/ember/personality.md +29 -1
  19. package/agents/flux/personality.md +248 -0
  20. package/agents/furnace/personality.md +52 -1
  21. package/agents/herald/personality.md +3 -1
  22. package/agents/loki/personality.md +108 -0
  23. package/agents/oracle/personality.md +284 -0
  24. package/agents/pixel/personality.md +140 -0
  25. package/agents/planning-hub/personality.md +222 -0
  26. package/agents/scribe/personality.md +3 -1
  27. package/agents/slag/personality.md +268 -0
  28. package/agents/{sentinel → temper}/personality.md +85 -9
  29. package/bin/cli.js +77 -30
  30. package/bin/dashboard/api/agents.js +333 -0
  31. package/bin/dashboard/api/dispatch.js +507 -0
  32. package/bin/dashboard/api/tasks.js +416 -0
  33. package/bin/dashboard/public/assets/index-BpHfsx1r.js +2 -0
  34. package/bin/dashboard/public/assets/index-QODv4Zn9.css +1 -0
  35. package/bin/dashboard/public/index.html +14 -0
  36. package/bin/dashboard/server.js +645 -0
  37. package/bin/forge-daemon.sh +176 -550
  38. package/bin/forge-setup.sh +28 -11
  39. package/bin/forge-spawn.sh +5 -5
  40. package/bin/forge.cmd +83 -83
  41. package/bin/forge.sh +210 -31
  42. package/config/agent-manifest.yaml +237 -243
  43. package/config/agents.json +207 -132
  44. package/config/task-types.yaml +111 -106
  45. package/context/agent-overrides/README.md +41 -0
  46. package/context/architecture.md +42 -0
  47. package/context/modern-conventions.md +129 -129
  48. package/docs/agents.md +473 -409
  49. package/docs/architecture.md +194 -162
  50. package/docs/commands.md +451 -388
  51. package/docs/security.md +195 -144
  52. package/package.json +38 -11
  53. package/src/lib/check-aliases.js +50 -0
  54. package/{bin → src}/lib/colors.sh +2 -1
  55. package/src/lib/config.sh +347 -0
  56. package/{bin → src}/lib/constants.sh +48 -13
  57. package/src/lib/daemon/budgets.sh +107 -0
  58. package/src/lib/daemon/dependencies.sh +146 -0
  59. package/src/lib/daemon/display.sh +128 -0
  60. package/src/lib/daemon/notifications.sh +273 -0
  61. package/src/lib/daemon/routing.sh +93 -0
  62. package/src/lib/daemon/state.sh +163 -0
  63. package/src/lib/daemon/sync.sh +103 -0
  64. package/{bin → src}/lib/database.sh +52 -0
  65. package/src/lib/frontmatter.js +106 -0
  66. package/src/lib/heimdall-setup.js +113 -0
  67. package/src/lib/heimdall.js +265 -0
  68. package/src/lib/index.sh +25 -0
  69. package/{bin → src}/lib/json.sh +7 -1
  70. package/{bin → src}/lib/terminal.js +7 -1
  71. package/.claude/settings.local.json +0 -33
  72. package/agents/forge-master/capabilities.md +0 -144
  73. package/agents/forge-master/context-template.md +0 -128
  74. package/agents/forge-master/personality.md +0 -138
  75. package/bin/lib/config.sh +0 -313
  76. package/config/task-template.md +0 -87
  77. package/context/forge-state.yaml +0 -19
  78. package/docs/TODO.md +0 -150
  79. package/docs/getting-started.md +0 -243
  80. package/docs/npm-publishing.md +0 -95
  81. package/docs/workflows/README.md +0 -32
  82. package/docs/workflows/azure-devops.md +0 -108
  83. package/docs/workflows/bitbucket.md +0 -104
  84. package/docs/workflows/git-only.md +0 -130
  85. package/docs/workflows/gitea.md +0 -168
  86. package/docs/workflows/github.md +0 -103
  87. package/docs/workflows/gitlab.md +0 -105
  88. package/docs/workflows.md +0 -454
  89. package/tasks/completed/ARCH-001-duplicate-agent-config.md +0 -121
  90. package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +0 -88
  91. package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +0 -77
  92. package/tasks/completed/ARCH-009-test-organization.md +0 -78
  93. package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +0 -94
  94. package/tasks/completed/ARCH-012-tmp-files-in-root.md +0 -71
  95. package/tasks/completed/ARCH-013-exit-code-constants.md +0 -65
  96. package/tasks/completed/ARCH-014-sed-incompatibility.md +0 -96
  97. package/tasks/completed/ARCH-015-docs-todo-tracking.md +0 -83
  98. package/tasks/completed/CLEAN-001.md +0 -38
  99. package/tasks/completed/CLEAN-003.md +0 -47
  100. package/tasks/completed/CLEAN-004.md +0 -56
  101. package/tasks/completed/CLEAN-005.md +0 -75
  102. package/tasks/completed/CLEAN-006.md +0 -47
  103. package/tasks/completed/CLEAN-007.md +0 -34
  104. package/tasks/completed/CLEAN-008.md +0 -49
  105. package/tasks/completed/CLEAN-012.md +0 -58
  106. package/tasks/completed/CLEAN-013.md +0 -45
  107. package/tasks/completed/SEC-001-sql-injection-fix.md +0 -58
  108. package/tasks/completed/SEC-002-notification-injection-fix.md +0 -45
  109. package/tasks/completed/SEC-003-eval-injection-fix.md +0 -54
  110. package/tasks/completed/SEC-004-pid-race-condition-fix.md +0 -49
  111. package/tasks/completed/SEC-005-worker-loop-path-fix.md +0 -51
  112. package/tasks/completed/SEC-006-eval-agent-names.md +0 -55
  113. package/tasks/completed/SEC-007-spawn-escaping.md +0 -67
  114. package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +0 -72
  115. package/tasks/pending/ARCH-005-missing-src-directory.md +0 -95
  116. package/tasks/pending/ARCH-006-task-template-location.md +0 -64
  117. package/tasks/pending/ARCH-007-daemon-monolith.md +0 -91
  118. package/tasks/pending/ARCH-008-forge-master-vs-hub.md +0 -81
  119. package/tasks/pending/ARCH-010-missing-index-files.md +0 -84
  120. package/tasks/pending/CLEAN-002.md +0 -29
  121. package/tasks/pending/CLEAN-009.md +0 -31
  122. package/tasks/pending/CLEAN-010.md +0 -30
  123. package/tasks/pending/CLEAN-011.md +0 -30
  124. package/tasks/pending/CLEAN-014.md +0 -32
  125. package/tasks/review/task-001.md +0 -78
  126. /package/{bin → src}/lib/agents.sh +0 -0
  127. /package/{bin → src}/lib/util.sh +0 -0
  128. /package/{bin → src}/lib/vcs.js +0 -0
  129. /package/{context → templates}/project-context-template.md +0 -0
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # src/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
+ }
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # src/lib/daemon/sync.sh
4
+ #
5
+ # Agent status synchronization functions - JSON files to SQLite
6
+ #
7
+ # Dependencies: json.sh, database.sh, constants.sh
8
+ # Globals required: FORGE_ROOT, FORGE_DB, AGENT_STATUS_DIR, LOG_FILE,
9
+ # STALE_STATUS_THRESHOLD
10
+
11
+ # Prevent double-sourcing
12
+ [[ -n "${_DAEMON_SYNC_LOADED:-}" ]] && return 0
13
+ _DAEMON_SYNC_LOADED=1
14
+
15
+ # Sync agent status from JSON files to SQLite (with mtime filtering)
16
+ sync_agent_status_to_db() {
17
+ local status_dir="$FORGE_ROOT/$AGENT_STATUS_DIR"
18
+
19
+ if [[ ! -d "$status_dir" ]]; then
20
+ return 0
21
+ fi
22
+
23
+ for status_file in "$status_dir"/*.json; do
24
+ if [[ -f "$status_file" && ! -L "$status_file" ]]; then
25
+ # Get file modification time
26
+ local file_mtime
27
+ file_mtime=$(stat -c %Y "$status_file" 2>/dev/null || stat -f %m "$status_file" 2>/dev/null || echo "0")
28
+
29
+ # Get agent name from filename
30
+ local agent_name
31
+ agent_name=$(basename "$status_file" .json)
32
+
33
+ # Check if file has changed since last read
34
+ local stored_mtime
35
+ stored_mtime=$(db_get_agent_mtime "$agent_name")
36
+
37
+ if [[ "$file_mtime" -gt "$stored_mtime" ]]; then
38
+ # File changed - parse and update DB
39
+ local agent status task message updated
40
+ agent=$(json_read "$status_file" "agent" "unknown")
41
+ status=$(json_read "$status_file" "status" "unknown")
42
+ task=$(json_read "$status_file" "task" "")
43
+ message=$(json_read "$status_file" "message" "" | head -c 80)
44
+ updated=$(json_read "$status_file" "updated" "")
45
+
46
+ # Check if status actually changed (T1-D3: record transitions)
47
+ local old_status
48
+ old_status=$(sqlite3 "$FORGE_DB" \
49
+ "SELECT status FROM agent_status WHERE agent = '$(db_escape "$agent")';" 2>/dev/null || echo "")
50
+ if [[ "$old_status" != "$status" && -n "$status" ]]; then
51
+ db_record_status_history "$agent" "$status" "$task"
52
+ fi
53
+
54
+ # Upsert to database
55
+ db_upsert_agent_status "$agent" "$status" "$task" "$message" "$updated" "$file_mtime"
56
+
57
+ echo "[$(date -Iseconds)] Synced status for $agent: $status" >> "$LOG_FILE"
58
+ fi
59
+ fi
60
+ done
61
+ }
62
+
63
+ # Build worker status from SQLite (for YAML output)
64
+ build_worker_status() {
65
+ local now_epoch
66
+ now_epoch=$(date +%s)
67
+ local stale_threshold=$STALE_STATUS_THRESHOLD
68
+
69
+ # Check if we have any agent status in DB
70
+ local agent_count
71
+ agent_count=$(sqlite3 "$FORGE_DB" "SELECT COUNT(*) FROM agent_status;" 2>/dev/null || echo "0")
72
+
73
+ if [[ "$agent_count" -eq 0 ]]; then
74
+ return 0
75
+ fi
76
+
77
+ echo "workers:"
78
+
79
+ # Read from database
80
+ while IFS='|' read -r agent status task message updated; do
81
+ local stale_marker=""
82
+
83
+ # Check if stale
84
+ if [[ -n "$updated" ]]; then
85
+ local updated_epoch age
86
+ 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")
87
+ age=$((now_epoch - updated_epoch))
88
+ if [[ "$age" -gt "$stale_threshold" ]]; then
89
+ stale_marker=" (stale)"
90
+ fi
91
+ fi
92
+
93
+ echo " - agent: $agent"
94
+ echo " status: $status$stale_marker"
95
+ if [[ -n "$task" ]]; then
96
+ echo " task: $task"
97
+ fi
98
+ if [[ -n "$message" ]]; then
99
+ echo " message: \"$message\""
100
+ fi
101
+ echo " updated: $updated"
102
+ done < <(db_get_all_agent_statuses)
103
+ }
@@ -51,10 +51,57 @@ db_require_init() {
51
51
  return 0
52
52
  }
53
53
 
54
+ # Check if sqlite3 is available; attempt install if missing
55
+ db_require_sqlite3() {
56
+ if command -v sqlite3 &>/dev/null; then
57
+ return 0
58
+ fi
59
+
60
+ echo "sqlite3 not found. Attempting to install..." >&2
61
+
62
+ # Try platform-appropriate package manager
63
+ if command -v apt-get &>/dev/null; then
64
+ echo "Installing sqlite3 via apt (may require sudo password)..." >&2
65
+ sudo apt-get install -y sqlite3 2>/dev/null && command -v sqlite3 &>/dev/null && return 0
66
+ elif command -v brew &>/dev/null; then
67
+ echo "Installing sqlite3 via Homebrew..." >&2
68
+ brew install sqlite3 2>/dev/null && command -v sqlite3 &>/dev/null && return 0
69
+ elif command -v pacman &>/dev/null; then
70
+ echo "Installing sqlite via pacman (may require sudo password)..." >&2
71
+ sudo pacman -S --noconfirm sqlite 2>/dev/null && command -v sqlite3 &>/dev/null && return 0
72
+ elif command -v dnf &>/dev/null; then
73
+ echo "Installing sqlite via dnf (may require sudo password)..." >&2
74
+ sudo dnf install -y sqlite 2>/dev/null && command -v sqlite3 &>/dev/null && return 0
75
+ elif command -v winget &>/dev/null; then
76
+ echo "Installing SQLite via winget. You may see a UAC prompt; please approve it." >&2
77
+ winget install --id SQLite.SQLite -e --silent 2>/dev/null
78
+ # winget installs to Program Files but doesn't add to Git Bash PATH
79
+ local winget_sqlite="/c/Program Files/SQLite/sqlite3.exe"
80
+ if [[ -f "$winget_sqlite" ]]; then
81
+ export PATH="$PATH:/c/Program Files/SQLite"
82
+ command -v sqlite3 &>/dev/null && return 0
83
+ fi
84
+ elif command -v choco &>/dev/null; then
85
+ echo "Installing sqlite via Chocolatey. You may see a UAC prompt; please approve it." >&2
86
+ choco install sqlite -y 2>/dev/null && command -v sqlite3 &>/dev/null && return 0
87
+ fi
88
+
89
+ echo "ERROR: sqlite3 is required but could not be installed automatically." >&2
90
+ echo "Please install sqlite3 manually:" >&2
91
+ echo " macOS: brew install sqlite3" >&2
92
+ echo " Ubuntu: sudo apt-get install sqlite3" >&2
93
+ echo " Fedora: sudo dnf install sqlite" >&2
94
+ echo " Windows: choco install sqlite" >&2
95
+ echo " or: winget install SQLite.SQLite" >&2
96
+ echo " (then add the install folder to your PATH)" >&2
97
+ return 1
98
+ }
99
+
54
100
  # Initialize the database with schema
55
101
  # Requires: FORGE_DB must be set before calling
56
102
  db_init() {
57
103
  db_require_init || return 1
104
+ db_require_sqlite3 || return 1
58
105
  local db_dir
59
106
  db_dir=$(dirname "$FORGE_DB")
60
107
 
@@ -62,6 +109,11 @@ db_init() {
62
109
  mkdir -p "$db_dir"
63
110
  chmod 700 "$db_dir"
64
111
 
112
+ # Enable WAL mode for concurrent reads without blocking writes.
113
+ # WAL + NORMAL synchronous is the recommended pairing for local tooling:
114
+ # faster than DELETE+FULL while still durable against OS crashes.
115
+ sqlite3 "$FORGE_DB" "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;"
116
+
65
117
  # Create tables if they don't exist
66
118
  sqlite3 "$FORGE_DB" <<'SQL'
67
119
  -- Daemon configuration (single row)
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * frontmatter.js - Safe YAML frontmatter field extractor
4
+ *
5
+ * Replaces grep/cut pipelines for extracting frontmatter fields from
6
+ * task and attention markdown files (RT-20260405-001 MEDIUM-5).
7
+ *
8
+ * Usage:
9
+ * node frontmatter.js <file> <field1> [field2] ...
10
+ * node frontmatter.js --section <file> <heading>
11
+ *
12
+ * Output (field mode):
13
+ * field1=value1
14
+ * field2=value2
15
+ *
16
+ * Output (section mode):
17
+ * First non-heading line under the matched ## heading
18
+ *
19
+ * Values are sanitized for safe shell consumption:
20
+ * - Shell metacharacters removed
21
+ * - Length capped at 200 chars
22
+ * - Missing fields output as empty: field=
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const yaml = require('js-yaml');
29
+
30
+ const MAX_VALUE_LENGTH = 200;
31
+
32
+ // Strip characters unsafe for shell interpolation
33
+ function sanitize(val) {
34
+ if (val == null) return '';
35
+ const str = String(val)
36
+ .replace(/[\0\r]/g, '')
37
+ .replace(/[\n]/g, ' ')
38
+ .replace(/[$`"'\\(){}[\]!#;|&<>]/g, '')
39
+ .trim();
40
+ return str.substring(0, MAX_VALUE_LENGTH);
41
+ }
42
+
43
+ function extractFrontmatter(content) {
44
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
45
+ if (!match) return {};
46
+ try {
47
+ const parsed = yaml.load(match[1]);
48
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
49
+ } catch (_) {
50
+ return {};
51
+ }
52
+ }
53
+
54
+ function extractSection(content, heading) {
55
+ const pattern = new RegExp(`^## ${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'im');
56
+ const idx = content.search(pattern);
57
+ if (idx === -1) return '';
58
+ const after = content.substring(idx).split('\n').slice(1);
59
+ for (const line of after) {
60
+ if (line.startsWith('## ')) break;
61
+ const trimmed = line.trim();
62
+ if (trimmed) return sanitize(trimmed);
63
+ }
64
+ return '';
65
+ }
66
+
67
+ // Main
68
+ const args = process.argv.slice(2);
69
+
70
+ if (args.length < 2) {
71
+ process.stderr.write('Usage: node frontmatter.js <file> <field1> [field2] ...\n');
72
+ process.stderr.write(' node frontmatter.js --section <file> <heading>\n');
73
+ process.exit(1);
74
+ }
75
+
76
+ // Section mode: --section <file> <heading>
77
+ if (args[0] === '--section') {
78
+ const file = args[1];
79
+ const heading = args[2];
80
+ if (!file || !heading) {
81
+ process.stderr.write('Usage: node frontmatter.js --section <file> <heading>\n');
82
+ process.exit(1);
83
+ }
84
+ let content;
85
+ try { content = fs.readFileSync(file, 'utf8'); } catch (_) { process.exit(0); }
86
+ process.stdout.write(extractSection(content, heading) + '\n');
87
+ process.exit(0);
88
+ }
89
+
90
+ // Field mode: <file> <field1> [field2] ...
91
+ const file = args[0];
92
+ const fields = args.slice(1);
93
+
94
+ let content;
95
+ try { content = fs.readFileSync(file, 'utf8'); } catch (_) {
96
+ // File unreadable: output empty values
97
+ for (const f of fields) process.stdout.write(`${f}=\n`);
98
+ process.exit(0);
99
+ }
100
+
101
+ const data = extractFrontmatter(content);
102
+
103
+ for (const field of fields) {
104
+ const val = sanitize(data[field]);
105
+ process.stdout.write(`${field}=${val}\n`);
106
+ }
@@ -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 }