orchestrix 15.14.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.
@@ -0,0 +1,374 @@
1
+ #!/bin/bash
2
+ # Orchestrix HANDOFF Detector - tmux Automation Hook (MCP Version)
3
+ # Triggers on Claude Code Stop event, detects HANDOFF and routes to target agent
4
+ #
5
+ # Design principles:
6
+ # - NO dependency on environment variables (robust)
7
+ # - Scans ALL tmux windows to find HANDOFF message
8
+ # - Uses hash-based deduplication to prevent re-processing
9
+ # - Background process handles cleanup and lock release
10
+ #
11
+ # Pro/Team Feature: This script is only available for Pro and Team subscribers.
12
+
13
+ set +e # Don't exit on errors
14
+
15
+ # ============================================
16
+ # Configuration
17
+ # ============================================
18
+ # Try to get session from env, otherwise detect from tmux context
19
+ SESSION_NAME="${ORCHESTRIX_SESSION:-}"
20
+
21
+ # Agent mappings
22
+ get_agent_name() {
23
+ case "$1" in
24
+ 0) echo "architect" ;;
25
+ 1) echo "sm" ;;
26
+ 2) echo "dev" ;;
27
+ 3) echo "qa" ;;
28
+ *) echo "" ;;
29
+ esac
30
+ }
31
+
32
+ get_window_num() {
33
+ case "$1" in
34
+ architect) echo "0" ;;
35
+ sm) echo "1" ;;
36
+ dev) echo "2" ;;
37
+ qa) echo "3" ;;
38
+ *) echo "" ;;
39
+ esac
40
+ }
41
+
42
+ # MCP version: use /o {agent} format
43
+ get_agent_command() {
44
+ case "$1" in
45
+ architect) echo "/o architect" ;;
46
+ sm) echo "/o sm" ;;
47
+ dev) echo "/o dev" ;;
48
+ qa) echo "/o qa" ;;
49
+ *) echo "" ;;
50
+ esac
51
+ }
52
+
53
+ # Infer target agent from command (for simplified format like "*develop-story 10.4")
54
+ get_target_from_command() {
55
+ local cmd="$1"
56
+ case "$cmd" in
57
+ develop-story|apply-qa-fixes|quick-develop)
58
+ echo "dev" ;;
59
+ review|quick-verify|test-design|finalize-commit)
60
+ echo "qa" ;;
61
+ draft|revise-story|revise|apply-proposal|create-next-story)
62
+ echo "sm" ;;
63
+ review-escalation|resolve-change)
64
+ echo "architect" ;;
65
+ *)
66
+ echo "" ;;
67
+ esac
68
+ }
69
+
70
+ # ============================================
71
+ # Find Orchestrix Session (before any logging)
72
+ # ============================================
73
+
74
+ # Priority 1: Explicit env var
75
+ # Priority 2: Detect current tmux session (if inside tmux and it's an orchestrix session)
76
+ # Priority 3: Scan all tmux sessions for orchestrix prefix
77
+ if [[ -z "$SESSION_NAME" && -n "$TMUX" ]]; then
78
+ CURRENT_SESSION=$(tmux display-message -p '#{session_name}' 2>/dev/null)
79
+ if [[ "$CURRENT_SESSION" == orchestrix* ]]; then
80
+ SESSION_NAME="$CURRENT_SESSION"
81
+ fi
82
+ fi
83
+
84
+ if [[ -z "$SESSION_NAME" ]]; then
85
+ SESSION_NAME=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -E '^orchestrix' | head -1)
86
+ fi
87
+
88
+ if [[ -z "$SESSION_NAME" ]]; then
89
+ exit 0
90
+ fi
91
+
92
+ if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
93
+ exit 0
94
+ fi
95
+
96
+ # Session-specific log file (all logging starts AFTER session detection)
97
+ LOG_FILE="/tmp/${SESSION_NAME}-handoff.log"
98
+ log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
99
+
100
+ log "========== Hook triggered =========="
101
+ log "Session: $SESSION_NAME"
102
+
103
+ # ============================================
104
+ # Scan All Windows for HANDOFF
105
+ # ============================================
106
+ PROCESSED_FILE="/tmp/${SESSION_NAME}-processed.txt"
107
+ touch "$PROCESSED_FILE"
108
+
109
+ SOURCE_WIN=""
110
+ TARGET=""
111
+ CMD=""
112
+ HANDOFF_HASH=""
113
+
114
+ for win in 0 1 2 3; do
115
+ OUTPUT=$(tmux capture-pane -t "$SESSION_NAME:$win" -p -S - 2>/dev/null)
116
+ [[ -z "$OUTPUT" ]] && continue
117
+
118
+ # Pattern 1: Standard HANDOFF format (🎯 HANDOFF TO agent: *command args)
119
+ LINE=$(echo "$OUTPUT" | grep -E '🎯.*HANDOFF.*TO' | tail -1)
120
+ if [[ -n "$LINE" ]]; then
121
+ # Calculate hash to avoid re-processing
122
+ HASH=$(echo "$LINE" | md5 2>/dev/null || echo "$LINE" | md5sum 2>/dev/null | cut -d' ' -f1)
123
+
124
+ # Skip if already processed
125
+ if grep -q "$HASH" "$PROCESSED_FILE" 2>/dev/null; then
126
+ continue
127
+ fi
128
+
129
+ # Parse HANDOFF message
130
+ if [[ "$LINE" =~ HANDOFF[[:space:]]+TO[[:space:]]+([a-zA-Z0-9_-]+):[[:space:]]*\*?([a-z0-9-]+)([[:space:]]+([0-9]+\.[0-9]+))? ]]; then
131
+ SOURCE_WIN=$win
132
+ TARGET=$(echo "${BASH_REMATCH[1]}" | tr '[:upper:]' '[:lower:]')
133
+ CMD="*${BASH_REMATCH[2]}${BASH_REMATCH[4]:+ ${BASH_REMATCH[4]}}"
134
+ HANDOFF_HASH=$HASH
135
+ log "Found HANDOFF in window $win: $LINE"
136
+ break
137
+ fi
138
+ fi
139
+
140
+ # Pattern 2: Simplified format - more flexible detection
141
+ # Handles: "*develop-story 10.4", "*draft", "* review 10.4", etc.
142
+ if [[ -z "$TARGET" ]]; then
143
+ # Search last 30 lines for command patterns (more tolerant)
144
+ LAST_LINES=$(echo "$OUTPUT" | tail -30)
145
+
146
+ # Try multiple patterns in order of specificity
147
+ # Pattern 2a: *command story_id (e.g., "*develop-story 10.4")
148
+ SIMPLE_LINE=$(echo "$LAST_LINES" | grep -E '\*[a-z]+-?[a-z-]*\s+[0-9]+\.[0-9]+' | tail -1)
149
+
150
+ if [[ -z "$SIMPLE_LINE" ]]; then
151
+ # Pattern 2b: *command without story_id (e.g., "*draft")
152
+ SIMPLE_LINE=$(echo "$LAST_LINES" | grep -E '^\s*\*[a-z]+-?[a-z-]*\s*$' | tail -1)
153
+ fi
154
+
155
+ if [[ -n "$SIMPLE_LINE" ]]; then
156
+ # Extract command and optional story_id
157
+ # Handle: "*develop-story 10.4", "* review 10.4", "*draft"
158
+ if [[ "$SIMPLE_LINE" =~ \*[[:space:]]*([a-z][a-z-]*)[[:space:]]*([0-9]+\.[0-9]+)? ]]; then
159
+ simple_cmd="${BASH_REMATCH[1]}"
160
+ story_id="${BASH_REMATCH[2]}"
161
+ inferred_target=$(get_target_from_command "$simple_cmd")
162
+
163
+ if [[ -n "$inferred_target" ]]; then
164
+ # Calculate hash
165
+ HASH=$(echo "$SIMPLE_LINE" | md5 2>/dev/null || echo "$SIMPLE_LINE" | md5sum 2>/dev/null | cut -d' ' -f1)
166
+
167
+ # Skip if already processed
168
+ if grep -q "$HASH" "$PROCESSED_FILE" 2>/dev/null; then
169
+ continue
170
+ fi
171
+
172
+ SOURCE_WIN=$win
173
+ TARGET=$inferred_target
174
+ if [[ -n "$story_id" ]]; then
175
+ CMD="*${simple_cmd} ${story_id}"
176
+ else
177
+ CMD="*${simple_cmd}"
178
+ fi
179
+ HANDOFF_HASH=$HASH
180
+ log "[SIMPLE] Found in window $win: '$SIMPLE_LINE' -> $TARGET: $CMD"
181
+ break
182
+ fi
183
+ fi
184
+ fi
185
+ fi
186
+ done
187
+
188
+ # No HANDOFF found - try fallback from pending-handoff file
189
+ if [[ -z "$TARGET" || -z "$CMD" ]]; then
190
+ log "No HANDOFF in terminal output, checking fallback file..."
191
+
192
+ # ============================================
193
+ # Fallback: Check pending-handoff.json
194
+ # ============================================
195
+ # Find project root (look for .orchestrix-core directory)
196
+ FALLBACK_FILE=""
197
+ for win in 0 1 2 3; do
198
+ # Get the pane's current directory
199
+ PANE_DIR=$(tmux display-message -t "$SESSION_NAME:$win" -p '#{pane_current_path}' 2>/dev/null)
200
+ if [[ -n "$PANE_DIR" && -f "$PANE_DIR/.orchestrix-core/runtime/pending-handoff.json" ]]; then
201
+ FALLBACK_FILE="$PANE_DIR/.orchestrix-core/runtime/pending-handoff.json"
202
+ SOURCE_WIN=$win
203
+ break
204
+ fi
205
+ done
206
+
207
+ if [[ -z "$FALLBACK_FILE" ]]; then
208
+ log "No pending-handoff.json found"
209
+ exit 0
210
+ fi
211
+
212
+ log "Found fallback file: $FALLBACK_FILE"
213
+
214
+ # Read and parse the JSON file
215
+ if command -v jq &>/dev/null; then
216
+ # Use jq if available
217
+ STATUS=$(jq -r '.status // "unknown"' "$FALLBACK_FILE" 2>/dev/null)
218
+ if [[ "$STATUS" != "pending" ]]; then
219
+ log "Fallback file status is '$STATUS', not 'pending'. Skipping."
220
+ exit 0
221
+ fi
222
+
223
+ TARGET=$(jq -r '.target_agent // ""' "$FALLBACK_FILE" 2>/dev/null)
224
+ CMD=$(jq -r '.command // ""' "$FALLBACK_FILE" 2>/dev/null)
225
+ STORY_ID=$(jq -r '.story_id // ""' "$FALLBACK_FILE" 2>/dev/null)
226
+ SOURCE_AGENT=$(jq -r '.source_agent // ""' "$FALLBACK_FILE" 2>/dev/null)
227
+ else
228
+ # Fallback to grep/sed parsing
229
+ STATUS=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$FALLBACK_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
230
+ if [[ "$STATUS" != "pending" ]]; then
231
+ log "Fallback file status is '$STATUS', not 'pending'. Skipping."
232
+ exit 0
233
+ fi
234
+
235
+ TARGET=$(grep -o '"target_agent"[[:space:]]*:[[:space:]]*"[^"]*"' "$FALLBACK_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
236
+ CMD=$(grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' "$FALLBACK_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
237
+ STORY_ID=$(grep -o '"story_id"[[:space:]]*:[[:space:]]*"[^"]*"' "$FALLBACK_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
238
+ SOURCE_AGENT=$(grep -o '"source_agent"[[:space:]]*:[[:space:]]*"[^"]*"' "$FALLBACK_FILE" | sed 's/.*"\([^"]*\)"$/\1/')
239
+ fi
240
+
241
+ if [[ -z "$TARGET" || -z "$CMD" ]]; then
242
+ log "Invalid fallback file content"
243
+ exit 0
244
+ fi
245
+
246
+ # Create a hash to prevent re-processing
247
+ # Include STORY_ID to differentiate handoffs for different stories with same command
248
+ HANDOFF_HASH=$(echo "fallback-$SOURCE_AGENT-$TARGET-$CMD-$STORY_ID" | md5 2>/dev/null || echo "fallback-$SOURCE_AGENT-$TARGET-$CMD-$STORY_ID" | md5sum 2>/dev/null | cut -d' ' -f1)
249
+
250
+ # Skip if already processed
251
+ if grep -q "$HANDOFF_HASH" "$PROCESSED_FILE" 2>/dev/null; then
252
+ log "Fallback handoff already processed"
253
+ exit 0
254
+ fi
255
+
256
+ # Get SOURCE_WIN from SOURCE_AGENT (override the window where file was found)
257
+ SOURCE_WIN=$(get_window_num "$SOURCE_AGENT")
258
+
259
+ log "[FALLBACK] Recovered handoff from file: $SOURCE_AGENT (win $SOURCE_WIN) -> $TARGET: $CMD"
260
+
261
+ # Mark the fallback file as completed
262
+ if command -v jq &>/dev/null; then
263
+ jq --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" '.status = "completed_by_fallback" | .completed_at = $ts' "$FALLBACK_FILE" > "$FALLBACK_FILE.tmp.$$" 2>/dev/null && mv "$FALLBACK_FILE.tmp.$$" "$FALLBACK_FILE"
264
+ else
265
+ sed -i.bak 's/"status"[[:space:]]*:[[:space:]]*"pending"/"status": "completed_by_fallback"/' "$FALLBACK_FILE" 2>/dev/null
266
+ rm -f "$FALLBACK_FILE.bak" 2>/dev/null
267
+ fi
268
+ fi
269
+
270
+ # Mark as processed
271
+ echo "$HANDOFF_HASH" >> "$PROCESSED_FILE"
272
+
273
+ # Keep processed file small (last 100 entries)
274
+ # Use PID in tmp filename to avoid race with concurrent hook instances or background processes
275
+ tail -100 "$PROCESSED_FILE" > "$PROCESSED_FILE.tmp.$$" 2>/dev/null && mv "$PROCESSED_FILE.tmp.$$" "$PROCESSED_FILE"
276
+
277
+ # Get source agent name (only if not already set by fallback)
278
+ if [[ -z "$SOURCE_AGENT" ]]; then
279
+ SOURCE_AGENT=$(get_agent_name "$SOURCE_WIN")
280
+ fi
281
+ TARGET_WIN=$(get_window_num "$TARGET")
282
+
283
+ # Validate
284
+ if [[ -z "$TARGET_WIN" ]]; then
285
+ log "ERROR: Unknown target agent '$TARGET'"
286
+ exit 0
287
+ fi
288
+
289
+ if [[ "$TARGET_WIN" == "$SOURCE_WIN" ]]; then
290
+ log "ERROR: Source and target are same window"
291
+ exit 0
292
+ fi
293
+
294
+ log "HANDOFF: $SOURCE_AGENT (win $SOURCE_WIN) -> $TARGET (win $TARGET_WIN)"
295
+ log "Command: $CMD"
296
+
297
+ # ============================================
298
+ # Atomic Lock
299
+ # ============================================
300
+ LOCK="/tmp/${SESSION_NAME}-${SOURCE_WIN}.lock"
301
+ LOCK_TIMEOUT=60
302
+
303
+ if ! mkdir "$LOCK" 2>/dev/null; then
304
+ if [[ -f "$LOCK/ts" ]]; then
305
+ ts=$(cat "$LOCK/ts" 2>/dev/null || echo 0)
306
+ now=$(date +%s)
307
+ age=$((now - ts))
308
+ if [[ $age -lt $LOCK_TIMEOUT ]]; then
309
+ log "SKIP: Window $SOURCE_WIN locked (${age}s ago)"
310
+ exit 0
311
+ fi
312
+ log "Stale lock (${age}s), cleaning"
313
+ fi
314
+ rm -rf "$LOCK" 2>/dev/null
315
+ mkdir "$LOCK" 2>/dev/null || { log "SKIP: lock race"; exit 0; }
316
+ fi
317
+ date +%s > "$LOCK/ts"
318
+
319
+ # ============================================
320
+ # Send to Target
321
+ # ============================================
322
+ log "Sending '$CMD' to $TARGET (window $TARGET_WIN)..."
323
+
324
+ if tmux send-keys -t "$SESSION_NAME:$TARGET_WIN" "$CMD" 2>/dev/null; then
325
+ sleep 0.5
326
+ tmux send-keys -t "$SESSION_NAME:$TARGET_WIN" Enter
327
+ log "SUCCESS: Command sent to $TARGET"
328
+ else
329
+ log "ERROR: Failed to send command"
330
+ rm -rf "$LOCK"
331
+ exit 0
332
+ fi
333
+
334
+ # ============================================
335
+ # Background: Clear & Reload Source Agent
336
+ # ============================================
337
+ RELOAD_CMD=$(get_agent_command "$SOURCE_AGENT")
338
+
339
+ (
340
+ log "[BG] Starting cleanup for $SOURCE_AGENT (window $SOURCE_WIN)"
341
+ sleep 2
342
+
343
+ # Clear
344
+ tmux send-keys -t "$SESSION_NAME:$SOURCE_WIN" "/clear" 2>/dev/null
345
+ sleep 0.5
346
+ tmux send-keys -t "$SESSION_NAME:$SOURCE_WIN" Enter
347
+ log "[BG] /clear sent to $SOURCE_AGENT"
348
+
349
+ sleep 5
350
+
351
+ # Reload
352
+ if [[ -n "$RELOAD_CMD" ]]; then
353
+ tmux send-keys -t "$SESSION_NAME:$SOURCE_WIN" "$RELOAD_CMD" 2>/dev/null
354
+ sleep 0.5
355
+ tmux send-keys -t "$SESSION_NAME:$SOURCE_WIN" Enter
356
+ log "[BG] Reload sent: $RELOAD_CMD"
357
+ sleep 15
358
+ fi
359
+
360
+ # Remove hash from processed file to allow future same-message HANDOFF
361
+ # This fixes the issue where repeated identical messages (e.g., "*draft") are skipped
362
+ if [[ -n "$HANDOFF_HASH" && -f "$PROCESSED_FILE" ]]; then
363
+ grep -v "^${HANDOFF_HASH}$" "$PROCESSED_FILE" > "$PROCESSED_FILE.tmp.$$" 2>/dev/null && mv -f "$PROCESSED_FILE.tmp.$$" "$PROCESSED_FILE"
364
+ log "[BG] Hash removed from processed file: $HANDOFF_HASH"
365
+ fi
366
+
367
+ # Release lock
368
+ rm -rf "$LOCK"
369
+ log "[BG] Cleanup complete, lock released"
370
+ ) >> "$LOG_FILE" 2>&1 &
371
+
372
+ log "Background process started (PID $!)"
373
+ log "========== Hook complete =========="
374
+ exit 0
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ // Embedded settings.local.json template for Claude Code Stop hooks
4
+
5
+ const SETTINGS_LOCAL_TEMPLATE = {
6
+ hooks: {
7
+ Stop: [
8
+ {
9
+ matcher: '',
10
+ hooks: [
11
+ {
12
+ type: 'command',
13
+ command: "bash -c 'cd \"$(git rev-parse --show-toplevel)\" && .orchestrix-core/scripts/handoff-detector.sh'",
14
+ },
15
+ ],
16
+ },
17
+ ],
18
+ },
19
+ };
20
+
21
+ module.exports = { SETTINGS_LOCAL_TEMPLATE };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // Shell scripts are loaded from sibling .sh files at runtime
7
+ // This avoids embedding 400+ line scripts as JS strings
8
+
9
+ function loadScript(name) {
10
+ const scriptPath = path.join(__dirname, name);
11
+ if (fs.existsSync(scriptPath)) {
12
+ return fs.readFileSync(scriptPath, 'utf-8');
13
+ }
14
+ throw new Error(`Embedded script not found: ${name}`);
15
+ }
16
+
17
+ module.exports = {
18
+ getStartScript() {
19
+ return loadScript('start-orchestrix.sh');
20
+ },
21
+ getHandoffScript() {
22
+ return loadScript('handoff-detector.sh');
23
+ },
24
+ };