@walwal-harness/cli 3.7.4 → 4.0.0-alpha.10

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,367 @@
1
+ #!/bin/bash
2
+ # harness-queue-manager.sh — Feature Queue Manager (v4.0)
3
+ #
4
+ # feature-list.json에서 depends_on 그래프를 읽어 topological sort 후
5
+ # feature-queue.json을 생성/관리한다.
6
+ #
7
+ # Commands:
8
+ # init feature-list.json → feature-queue.json 초기 생성
9
+ # dequeue <team> ready 큐에서 feature를 꺼내 team에 배정
10
+ # pass <fid> feature를 passed로 이동, blocked→ready 전이
11
+ # fail <fid> feature를 failed로 이동
12
+ # requeue <fid> failed feature를 ready로 복귀
13
+ # status 현재 큐 상태 출력
14
+ #
15
+ # Usage: bash scripts/harness-queue-manager.sh <command> [args...] [project-root]
16
+
17
+ set -uo pipefail
18
+
19
+ # ── Resolve project root ──
20
+ resolve_root() {
21
+ local dir="${1:-.}"
22
+ dir="$(cd "$dir" 2>/dev/null && pwd || echo "$dir")"
23
+ while [ "$dir" != "/" ]; do
24
+ if [ -d "$dir/.harness" ]; then echo "$dir"; return 0; fi
25
+ dir="$(dirname "$dir")"
26
+ done
27
+ return 1
28
+ }
29
+
30
+ CMD="${1:-status}"
31
+ shift || true
32
+
33
+ # Last arg might be project root
34
+ PROJECT_ROOT=""
35
+ for arg in "$@"; do
36
+ if [ -d "$arg/.harness" ]; then PROJECT_ROOT="$arg"; fi
37
+ done
38
+ if [ -z "$PROJECT_ROOT" ]; then
39
+ PROJECT_ROOT="$(resolve_root ".")" || { echo "[queue] .harness/ not found."; exit 1; }
40
+ fi
41
+
42
+ FEATURES="$PROJECT_ROOT/.harness/actions/feature-list.json"
43
+ QUEUE="$PROJECT_ROOT/.harness/actions/feature-queue.json"
44
+ CONFIG="$PROJECT_ROOT/.harness/config.json"
45
+ QUEUE_LOCK="$PROJECT_ROOT/.harness/.queue-lock"
46
+
47
+ # ── Atomic queue lock — prevent race conditions between teams ──
48
+ acquire_queue_lock() {
49
+ local max_wait=30 waited=0
50
+ while ! mkdir "$QUEUE_LOCK" 2>/dev/null; do
51
+ sleep 0.1
52
+ waited=$((waited + 1))
53
+ if [ "$waited" -ge $((max_wait * 10)) ]; then
54
+ rm -rf "$QUEUE_LOCK"
55
+ mkdir "$QUEUE_LOCK" 2>/dev/null || true
56
+ break
57
+ fi
58
+ done
59
+ }
60
+
61
+ release_queue_lock() {
62
+ rm -rf "$QUEUE_LOCK" 2>/dev/null || true
63
+ }
64
+
65
+ # ── Concurrency from config ──
66
+ CONCURRENCY=3
67
+ if [ -f "$CONFIG" ]; then
68
+ _c=$(jq -r '.flow.parallel.concurrency // 3' "$CONFIG" 2>/dev/null)
69
+ if [ "$_c" -gt 0 ] 2>/dev/null; then CONCURRENCY=$_c; fi
70
+ fi
71
+
72
+ # ══════════════════════════════════════════
73
+ # init — Build queue from feature-list.json
74
+ # ══════════════════════════════════════════
75
+ cmd_init() {
76
+ if [ ! -f "$FEATURES" ]; then
77
+ echo "[queue] feature-list.json not found."
78
+ exit 1
79
+ fi
80
+
81
+ # Build dependency graph and topological sort
82
+ # Output: feature-queue.json with ready (no deps) and blocked (has deps)
83
+ jq --argjson concurrency "$CONCURRENCY" '
84
+ # Build passed set (features already passed by evaluator)
85
+ def passed_set:
86
+ [.features[] | select(
87
+ ((.passes // []) | length > 0) and
88
+ ((.passes // []) | any(. == "evaluator-functional"))
89
+ ) | .id] ;
90
+
91
+ # Separate ready vs blocked
92
+ def classify(passed):
93
+ reduce .features[] as $f (
94
+ { ready: [], blocked: {} };
95
+ ($f.depends_on // []) as $deps |
96
+ if ($f.id | IN(passed[])) then . # already passed, skip
97
+ elif ($deps | length == 0) then
98
+ .ready += [$f.id]
99
+ elif (passed | length > 0) and ($deps | all(. as $d | $d | IN(passed[]))) then
100
+ .ready += [$f.id] # all deps satisfied
101
+ else
102
+ .blocked[$f.id] = ($deps - passed)
103
+ end
104
+ ) ;
105
+
106
+ passed_set as $passed |
107
+ classify($passed) as $classified |
108
+ {
109
+ version: "4.0",
110
+ concurrency: $concurrency,
111
+ queue: {
112
+ ready: $classified.ready,
113
+ blocked: $classified.blocked,
114
+ in_progress: {},
115
+ passed: $passed,
116
+ failed: []
117
+ },
118
+ teams: (
119
+ [range(1; $concurrency + 1)] | map({
120
+ key: (. | tostring),
121
+ value: { status: "idle", feature: null, branch: null, pid: null }
122
+ }) | from_entries
123
+ )
124
+ }
125
+ ' "$FEATURES" > "$QUEUE"
126
+
127
+ local ready_count blocked_count passed_count
128
+ ready_count=$(jq '.queue.ready | length' "$QUEUE")
129
+ blocked_count=$(jq '.queue.blocked | length' "$QUEUE")
130
+ passed_count=$(jq '.queue.passed | length' "$QUEUE")
131
+
132
+ echo "[queue] Initialized: $ready_count ready, $blocked_count blocked, $passed_count already passed"
133
+ echo "[queue] Concurrency: $CONCURRENCY teams"
134
+ }
135
+
136
+ # ══════════════════════════════════════════
137
+ # dequeue — Assign next ready feature to team
138
+ # ══════════════════════════════════════════
139
+ cmd_dequeue() {
140
+ local team_id="${1:-}"
141
+ if [ -z "$team_id" ]; then echo "[queue] Usage: dequeue <team_id>"; exit 1; fi
142
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first."; exit 1; fi
143
+
144
+ acquire_queue_lock
145
+
146
+ local feature
147
+ feature=$(jq -r '.queue.ready[0] // empty' "$QUEUE")
148
+
149
+ if [ -z "$feature" ]; then
150
+ release_queue_lock
151
+ echo "[queue] No features in ready queue."
152
+ local in_prog blocked
153
+ in_prog=$(jq '.queue.in_progress | length' "$QUEUE")
154
+ blocked=$(jq '.queue.blocked | length' "$QUEUE")
155
+ if [ "$in_prog" -eq 0 ] && [ "$blocked" -eq 0 ]; then
156
+ echo "[queue] ALL FEATURES COMPLETE."
157
+ fi
158
+ return 1
159
+ fi
160
+
161
+ jq --arg fid "$feature" --arg tid "$team_id" '
162
+ .queue.ready -= [$fid] |
163
+ .queue.in_progress[$fid] = { team: ($tid | tonumber), phase: "gen", attempt: 1 } |
164
+ .teams[$tid] = { status: "busy", feature: $fid, branch: ("feature/" + $fid), pid: null }
165
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
166
+
167
+ release_queue_lock
168
+ echo "$feature"
169
+ }
170
+
171
+ # ══════════════════════════════════════════
172
+ # pass — Mark feature as passed, unblock dependents
173
+ # ══════════════════════════════════════════
174
+ cmd_pass() {
175
+ local fid="${1:-}"
176
+ if [ -z "$fid" ]; then echo "[queue] Usage: pass <feature_id>"; exit 1; fi
177
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first."; exit 1; fi
178
+
179
+ acquire_queue_lock
180
+
181
+ local team_id
182
+ team_id=$(jq -r --arg fid "$fid" '.queue.in_progress[$fid].team // empty' "$QUEUE")
183
+
184
+ jq --arg fid "$fid" --arg tid "${team_id:-0}" '
185
+ # Remove from in_progress
186
+ del(.queue.in_progress[$fid]) |
187
+
188
+ # Add to passed
189
+ .queue.passed += [$fid] |
190
+ .queue.passed |= unique |
191
+
192
+ # Free team
193
+ (if $tid != "0" then
194
+ .teams[$tid] = { status: "idle", feature: null, branch: null, pid: null }
195
+ else . end) |
196
+
197
+ # Unblock dependents: for each blocked feature, remove $fid from its deps
198
+ # If deps become empty, move to ready
199
+ .queue.blocked as $blocked |
200
+ reduce ($blocked | keys[]) as $blocked_fid (
201
+ .;
202
+ .queue.blocked[$blocked_fid] -= [$fid] |
203
+ if (.queue.blocked[$blocked_fid] | length) == 0 then
204
+ del(.queue.blocked[$blocked_fid]) |
205
+ .queue.ready += [$blocked_fid]
206
+ else . end
207
+ )
208
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
209
+
210
+ local newly_ready
211
+ newly_ready=$(jq -r '.queue.ready | join(", ")' "$QUEUE")
212
+ release_queue_lock
213
+ echo "[queue] $fid PASSED. Ready: [$newly_ready]"
214
+ }
215
+
216
+ # ══════════════════════════════════════════
217
+ # fail — Mark feature as failed
218
+ # ══════════════════════════════════════════
219
+ cmd_fail() {
220
+ local fid="${1:-}"
221
+ if [ -z "$fid" ]; then echo "[queue] Usage: fail <feature_id>"; exit 1; fi
222
+ if [ ! -f "$QUEUE" ]; then exit 1; fi
223
+
224
+ acquire_queue_lock
225
+
226
+ local team_id
227
+ team_id=$(jq -r --arg fid "$fid" '.queue.in_progress[$fid].team // empty' "$QUEUE")
228
+
229
+ jq --arg fid "$fid" --arg tid "${team_id:-0}" '
230
+ del(.queue.in_progress[$fid]) |
231
+ .queue.failed += [$fid] |
232
+ .queue.failed |= unique |
233
+ (if $tid != "0" then
234
+ .teams[$tid] = { status: "idle", feature: null, branch: null, pid: null }
235
+ else . end)
236
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
237
+
238
+ release_queue_lock
239
+ echo "[queue] $fid FAILED."
240
+ }
241
+
242
+ # ══════════════════════════════════════════
243
+ # requeue — Move failed feature back to ready
244
+ # ══════════════════════════════════════════
245
+ cmd_requeue() {
246
+ local fid="${1:-}"
247
+ if [ -z "$fid" ]; then echo "[queue] Usage: requeue <feature_id>"; exit 1; fi
248
+
249
+ jq --arg fid "$fid" '
250
+ .queue.failed -= [$fid] |
251
+ .queue.ready += [$fid]
252
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
253
+
254
+ echo "[queue] $fid requeued to ready."
255
+ }
256
+
257
+ # ══════════════════════════════════════════
258
+ # update_phase — Update in_progress feature phase/attempt
259
+ # ══════════════════════════════════════════
260
+ cmd_update_phase() {
261
+ local fid="${1:-}" phase="${2:-}" attempt="${3:-}"
262
+ if [ -z "$fid" ] || [ -z "$phase" ]; then
263
+ echo "[queue] Usage: update_phase <feature_id> <phase> [attempt]"
264
+ exit 1
265
+ fi
266
+
267
+ acquire_queue_lock
268
+
269
+ local jq_expr
270
+ jq_expr=".queue.in_progress[\"$fid\"].phase = \"$phase\""
271
+ if [ -n "$attempt" ]; then
272
+ jq_expr="$jq_expr | .queue.in_progress[\"$fid\"].attempt = ($attempt | tonumber)"
273
+ fi
274
+
275
+ jq "$jq_expr" "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
276
+ release_queue_lock
277
+ }
278
+
279
+ # ══════════════════════════════════════════
280
+ # status — Print queue state
281
+ # ══════════════════════════════════════════
282
+ cmd_status() {
283
+ if [ ! -f "$QUEUE" ]; then
284
+ echo "[queue] Not initialized. Run: bash scripts/harness-queue-manager.sh init"
285
+ return
286
+ fi
287
+
288
+ local ready blocked in_prog passed failed
289
+ ready=$(jq -r '.queue.ready | length' "$QUEUE")
290
+ blocked=$(jq -r '.queue.blocked | length' "$QUEUE")
291
+ in_prog=$(jq -r '.queue.in_progress | length' "$QUEUE")
292
+ passed=$(jq -r '.queue.passed | length' "$QUEUE")
293
+ failed=$(jq -r '.queue.failed | length' "$QUEUE")
294
+ local total=$((ready + blocked + in_prog + passed + failed))
295
+
296
+ echo ""
297
+ echo " Feature Queue ($passed/$total done)"
298
+ echo " ─────────────────────────────"
299
+ echo " Ready: $ready"
300
+ echo " Blocked: $blocked"
301
+ echo " In Progress: $in_prog"
302
+ echo " Passed: $passed"
303
+ echo " Failed: $failed"
304
+ echo ""
305
+
306
+ # Team status
307
+ local team_count
308
+ team_count=$(jq '.teams | length' "$QUEUE")
309
+ echo " Teams"
310
+ echo " ─────────────────────────────"
311
+ for i in $(seq 1 "$team_count"); do
312
+ local t_status t_feature
313
+ t_status=$(jq -r ".teams[\"$i\"].status // \"idle\"" "$QUEUE")
314
+ t_feature=$(jq -r ".teams[\"$i\"].feature // \"—\"" "$QUEUE")
315
+ printf " Team %d: %-6s %s\n" "$i" "$t_status" "$t_feature"
316
+ done
317
+ echo ""
318
+
319
+ # In-progress details
320
+ if [ "$in_prog" -gt 0 ]; then
321
+ echo " In Progress"
322
+ echo " ─────────────────────────────"
323
+ jq -r '.queue.in_progress | to_entries[] | " \(.key): team \(.value.team) — \(.value.phase) (attempt \(.value.attempt))"' "$QUEUE"
324
+ echo ""
325
+ fi
326
+ }
327
+
328
+ # ══════════════════════════════════════════
329
+ # recover — Move stale in_progress back to ready (after studio restart)
330
+ # ══════════════════════════════════════════
331
+ cmd_recover() {
332
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Not initialized."; return; fi
333
+
334
+ local stale_count
335
+ stale_count=$(jq '.queue.in_progress | length' "$QUEUE" 2>/dev/null)
336
+
337
+ if [ "${stale_count:-0}" -eq 0 ]; then
338
+ echo "[queue] No stale in_progress entries."
339
+ return
340
+ fi
341
+
342
+ # Move all in_progress → ready, reset all teams to idle
343
+ jq '
344
+ .queue.ready += [.queue.in_progress | keys[]] |
345
+ .queue.ready |= unique |
346
+ .queue.in_progress = {} |
347
+ .teams |= with_entries(.value = { status: "idle", feature: null, branch: null, pid: null })
348
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
349
+
350
+ echo "[queue] Recovered ${stale_count} stale features back to ready queue."
351
+ }
352
+
353
+ # ── Dispatch ──
354
+ case "$CMD" in
355
+ init) cmd_init ;;
356
+ dequeue) cmd_dequeue "$@" ;;
357
+ pass) cmd_pass "$@" ;;
358
+ fail) cmd_fail "$@" ;;
359
+ requeue) cmd_requeue "$@" ;;
360
+ update_phase) cmd_update_phase "$@" ;;
361
+ recover) cmd_recover ;;
362
+ status) cmd_status ;;
363
+ *)
364
+ echo "Usage: harness-queue-manager.sh <init|dequeue|pass|fail|requeue|recover|update_phase|status> [args]"
365
+ exit 1
366
+ ;;
367
+ esac
@@ -26,6 +26,24 @@ current_agent=$(jq -r '.current_agent // "none"' "$PROGRESS" 2>/dev/null)
26
26
  next_agent=$(jq -r '.next_agent // "none"' "$PROGRESS" 2>/dev/null)
27
27
  agent_status=$(jq -r '.agent_status // "pending"' "$PROGRESS" 2>/dev/null)
28
28
 
29
+ # ─────────────────────────────────────────
30
+ # v4 Parallel Mode — feature-queue.json이 존재하면 v4 안내
31
+ # ─────────────────────────────────────────
32
+ FEATURE_QUEUE="$PROJECT_ROOT/.harness/actions/feature-queue.json"
33
+ if [ -f "$FEATURE_QUEUE" ]; then
34
+ passed=$(jq '.queue.passed | length' "$FEATURE_QUEUE" 2>/dev/null || echo 0)
35
+ total=$(jq '[.queue.ready, (.queue.blocked | keys), (.queue.in_progress | keys), .queue.passed, .queue.failed] | flatten | length' "$FEATURE_QUEUE" 2>/dev/null || echo 0)
36
+ in_prog=$(jq '.queue.in_progress | length' "$FEATURE_QUEUE" 2>/dev/null || echo 0)
37
+ failed=$(jq '.queue.failed | length' "$FEATURE_QUEUE" 2>/dev/null || echo 0)
38
+
39
+ echo "# Harness v4 — Parallel Agent Teams active"
40
+ echo "# Queue: ${passed}/${total} passed, ${in_prog} in progress, ${failed} failed"
41
+ echo "# Teams are running autonomously. You are the orchestrator."
42
+ echo "# Role: Monitor dashboard, resolve failures, manual interventions only."
43
+ echo "# Do NOT run /harness-generator-* or /harness-evaluator-* — Teams handle Gen+Eval."
44
+ exit 0
45
+ fi
46
+
29
47
  # ─────────────────────────────────────────
30
48
  # init 상태: 첫 안내
31
49
  # ─────────────────────────────────────────
@@ -0,0 +1,122 @@
1
+ #!/bin/bash
2
+ # harness-studio-v4.sh — Harness Studio v4: 3-Column Layout
3
+ #
4
+ # ┌──────────────┬──────────────┬──────────────┐
5
+ # │ │ │ Team 1 │
6
+ # │ │ ├──────────────┤
7
+ # │ Main │ Dashboard │ Team 2 │
8
+ # │ (Claude) │ (read-only) ├──────────────┤
9
+ # │ │ │ Team 3 │
10
+ # └──────────────┴──────────────┴──────────────┘
11
+ #
12
+ # Main: 사용자가 직접 Claude Code를 실행하는 대화형 세션
13
+ # Dashboard: feature-queue + team status 자동 갱신 (입력 불가)
14
+ # Team 1~3: claude -p headless worker (입력 불가, 로그만 표시)
15
+ #
16
+ # Usage:
17
+ # bash scripts/harness-studio-v4.sh [project-root]
18
+ # bash scripts/harness-studio-v4.sh --kill
19
+
20
+ set -euo pipefail
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
23
+ SESSION_NAME="harness-v4"
24
+
25
+ PROJECT_ROOT=""
26
+
27
+ for arg in "$@"; do
28
+ case "$arg" in
29
+ --kill)
30
+ tmux kill-session -t "$SESSION_NAME" 2>/dev/null && echo "Killed." || echo "No session."
31
+ exit 0
32
+ ;;
33
+ *)
34
+ if [ -d "$arg" ]; then PROJECT_ROOT="$arg"; fi
35
+ ;;
36
+ esac
37
+ done
38
+
39
+ if [ -z "$PROJECT_ROOT" ]; then
40
+ dir="$(pwd)"
41
+ while [ "$dir" != "/" ]; do
42
+ if [ -d "$dir/.harness" ]; then PROJECT_ROOT="$dir"; break; fi
43
+ dir="$(dirname "$dir")"
44
+ done
45
+ fi
46
+
47
+ if [ -z "$PROJECT_ROOT" ] || [ ! -d "$PROJECT_ROOT/.harness" ]; then
48
+ echo "Error: .harness/ not found."
49
+ exit 1
50
+ fi
51
+
52
+ echo "Project: $PROJECT_ROOT"
53
+ echo "Session: $SESSION_NAME"
54
+
55
+ tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
56
+
57
+ # ── Initialize or recover queue ──
58
+ QUEUE="$PROJECT_ROOT/.harness/actions/feature-queue.json"
59
+ if [ ! -f "$QUEUE" ]; then
60
+ echo "Initializing feature queue..."
61
+ bash "$SCRIPT_DIR/harness-queue-manager.sh" init "$PROJECT_ROOT"
62
+ else
63
+ echo "Recovering stale queue state..."
64
+ bash "$SCRIPT_DIR/harness-queue-manager.sh" recover "$PROJECT_ROOT"
65
+ fi
66
+
67
+ # ══════════════════════════════════════════
68
+ # 3-Column Layout (Main | Dashboard | Teams)
69
+ # ══════════════════════════════════════════
70
+
71
+ # Column 1: Main (interactive shell — user runs claude here)
72
+ PANE_MAIN=$(tmux new-session -d -s "$SESSION_NAME" -c "$PROJECT_ROOT" -x 220 -y 55 \
73
+ -P -F '#{pane_id}')
74
+
75
+ # Column 2: Dashboard (split right from Main, 66% remaining → 33% each of 3 cols)
76
+ PANE_DASH=$(tmux split-window -h -p 66 -t "$PANE_MAIN" -c "$PROJECT_ROOT" \
77
+ -P -F '#{pane_id}' \
78
+ "bash --norc --noprofile -c 'exec bash \"${SCRIPT_DIR}/harness-dashboard-v4.sh\" \"${PROJECT_ROOT}\"'")
79
+
80
+ # Column 3: Team 1 (split right from Dashboard, 50% of remaining = 33% total)
81
+ PANE_T1=$(tmux split-window -h -p 50 -t "$PANE_DASH" -c "$PROJECT_ROOT" \
82
+ -P -F '#{pane_id}' \
83
+ "bash --norc --noprofile -c 'exec bash \"${SCRIPT_DIR}/harness-team-worker.sh\" 1 \"${PROJECT_ROOT}\"'")
84
+
85
+ # Team 2 (split below Team 1)
86
+ PANE_T2=$(tmux split-window -v -p 66 -t "$PANE_T1" -c "$PROJECT_ROOT" \
87
+ -P -F '#{pane_id}' \
88
+ "bash --norc --noprofile -c 'exec bash \"${SCRIPT_DIR}/harness-team-worker.sh\" 2 \"${PROJECT_ROOT}\"'")
89
+
90
+ # Team 3 (split below Team 2)
91
+ PANE_T3=$(tmux split-window -v -p 50 -t "$PANE_T2" -c "$PROJECT_ROOT" \
92
+ -P -F '#{pane_id}' \
93
+ "bash --norc --noprofile -c 'exec bash \"${SCRIPT_DIR}/harness-team-worker.sh\" 3 \"${PROJECT_ROOT}\"'")
94
+
95
+ # ── Launch Claude in Main pane ──
96
+ tmux send-keys -t "$PANE_MAIN" "unset npm_config_prefix 2>/dev/null; clear && claude --dangerously-skip-permissions" Enter
97
+
98
+ # ── Pane titles ──
99
+ tmux select-pane -t "$PANE_MAIN" -T "Main"
100
+ tmux select-pane -t "$PANE_DASH" -T "Dashboard"
101
+ tmux select-pane -t "$PANE_T1" -T "Team 1"
102
+ tmux select-pane -t "$PANE_T2" -T "Team 2"
103
+ tmux select-pane -t "$PANE_T3" -T "Team 3"
104
+
105
+ tmux set-option -t "$SESSION_NAME" pane-border-status top 2>/dev/null || true
106
+ tmux set-option -t "$SESSION_NAME" pane-border-format " #{pane_title} " 2>/dev/null || true
107
+
108
+ # ── Focus Main pane ──
109
+ tmux select-pane -t "$PANE_MAIN"
110
+
111
+ # ── Attach ──
112
+ if [ -n "${TMUX:-}" ]; then
113
+ tmux switch-client -t "$SESSION_NAME"
114
+ else
115
+ echo ""
116
+ echo "Launching Harness Studio v4..."
117
+ echo " Main (left) : Interactive — run 'claude' here"
118
+ echo " Dashboard (mid) : Feature Queue + Team status (auto-refresh)"
119
+ echo " Team 1-3 (right) : Parallel workers (headless, log only)"
120
+ echo ""
121
+ tmux attach -t "$SESSION_NAME"
122
+ fi