@walwal-harness/cli 6.0.5 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/commands/harness-solo.md +20 -20
  3. package/commands/harness-team.md +1 -1
  4. package/gotchas/coo-developer.md +13 -0
  5. package/gotchas/documentationer.md +13 -0
  6. package/package.json +1 -1
  7. package/scripts/conductor-tick.sh +416 -0
  8. package/scripts/harness-archive.sh +123 -0
  9. package/scripts/harness-dashboard-up.sh +72 -0
  10. package/scripts/harness-dashboard.sh +509 -0
  11. package/scripts/harness-goal-init.sh +72 -0
  12. package/scripts/harness-goal-show.sh +37 -0
  13. package/scripts/harness-gotcha-memory.sh +348 -0
  14. package/scripts/harness-gotcha-register.sh +393 -0
  15. package/scripts/harness-meeting-doc.sh +153 -0
  16. package/scripts/harness-monitor.sh +398 -0
  17. package/scripts/harness-next.sh +577 -0
  18. package/scripts/harness-progress-set.sh +41 -0
  19. package/scripts/harness-prompt-history.sh +164 -0
  20. package/scripts/harness-queue-manager.sh +694 -0
  21. package/scripts/harness-session-start.sh +140 -0
  22. package/scripts/harness-statusline.sh +139 -0
  23. package/scripts/harness-task-session.sh +48 -0
  24. package/scripts/harness-tmux.sh +372 -0
  25. package/scripts/harness-user-prompt-submit.sh +123 -0
  26. package/scripts/init-agents-md.sh +337 -0
  27. package/scripts/init-ref-docs.sh +262 -0
  28. package/scripts/lib/harness-audit.sh +113 -0
  29. package/scripts/lib/harness-guardrail.sh +148 -0
  30. package/scripts/lib/harness-keywait.sh +57 -0
  31. package/scripts/lib/harness-render-progress.sh +346 -0
  32. package/scripts/scan-project.sh +367 -0
  33. package/skills/brainstorming/SKILL.md +4 -4
  34. package/skills/conductor/SKILL.md +19 -16
  35. package/skills/coo-developer/SKILL.md +42 -0
  36. package/skills/cqo/SKILL.md +7 -0
  37. package/skills/cto/SKILL.md +7 -0
  38. package/skills/dispatcher/SKILL.md +17 -28
  39. package/skills/dispatcher/persona-ceo.md +2 -1
  40. package/skills/documentationer/SKILL.md +46 -0
  41. package/skills/evaluator-code-quality/SKILL.md +1 -1
  42. package/skills/evaluator-functional/SKILL.md +1 -1
  43. package/skills/evaluator-visual/SKILL.md +1 -1
  44. package/skills/generator-backend/SKILL.md +1 -1
  45. package/skills/generator-frontend/SKILL.md +1 -1
  46. package/skills/meeting-manager/SKILL.md +34 -1
  47. package/skills/planner/SKILL.md +23 -9
  48. package/skills/planner/persona-coo-hr.md +17 -0
  49. package/skills/service-ops/SKILL.md +19 -4
@@ -0,0 +1,694 @@
1
+ #!/bin/bash
2
+ # harness-queue-manager.sh — Feature Queue Manager (v5 unified)
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.team.concurrency // .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
+ # Usage: init [sprint_number]
75
+ # sprint_number: optional, filter features by sprint (default: all)
76
+ # ══════════════════════════════════════════
77
+ cmd_init() {
78
+ local sprint_filter="${1:-all}"
79
+
80
+ if [ ! -f "$FEATURES" ]; then
81
+ echo "[queue] feature-list.json not found."
82
+ exit 1
83
+ fi
84
+
85
+ # Build dependency graph and topological sort
86
+ # Output: feature-queue.json with ready (no deps) and blocked (has deps)
87
+ jq --argjson concurrency "$CONCURRENCY" --arg sprint "$sprint_filter" '
88
+ # Build passed set (features already passed by evaluator)
89
+ def passed_set:
90
+ [.features[] | select(
91
+ ((.passes // []) | length > 0) and
92
+ ((.passes // []) | any(. == "evaluator-functional"))
93
+ ) | .id] ;
94
+
95
+ # Filter features by sprint if specified
96
+ def target_features:
97
+ if $sprint == "all" then .features
98
+ else [.features[] | select((.sprint // 1) == ($sprint | tonumber))]
99
+ end ;
100
+
101
+ # Separate ready vs blocked
102
+ def classify(features; passed):
103
+ reduce features[] as $f (
104
+ { ready: [], blocked: {} };
105
+ ($f.depends_on // []) as $deps |
106
+ if ($f.id | IN(passed[])) then . # already passed, skip
107
+ elif ($deps | length == 0) then
108
+ .ready += [$f.id]
109
+ elif (passed | length > 0) and ($deps | all(. as $d | $d | IN(passed[]))) then
110
+ .ready += [$f.id] # all deps satisfied
111
+ else
112
+ .blocked[$f.id] = ($deps - passed)
113
+ end
114
+ ) ;
115
+
116
+ passed_set as $passed |
117
+ target_features as $targets |
118
+ classify($targets; $passed) as $classified |
119
+ {
120
+ version: "5.0",
121
+ concurrency: $concurrency,
122
+ current_sprint: (if $sprint == "all" then null else ($sprint | tonumber) end),
123
+ queue: {
124
+ ready: $classified.ready,
125
+ blocked: $classified.blocked,
126
+ in_progress: {},
127
+ passed: $passed,
128
+ failed: []
129
+ },
130
+ teams: (
131
+ [range(1; $concurrency + 1)] | map({
132
+ key: (. | tostring),
133
+ value: { status: "idle", feature: null, branch: null, pid: null }
134
+ }) | from_entries
135
+ )
136
+ }
137
+ ' "$FEATURES" > "$QUEUE"
138
+
139
+ local ready_count blocked_count passed_count
140
+ ready_count=$(jq '.queue.ready | length' "$QUEUE")
141
+ blocked_count=$(jq '.queue.blocked | length' "$QUEUE")
142
+ passed_count=$(jq '.queue.passed | length' "$QUEUE")
143
+
144
+ echo "[queue] Initialized (sprint=$sprint_filter): $ready_count ready, $blocked_count blocked, $passed_count already passed"
145
+ echo "[queue] Concurrency: $CONCURRENCY teams"
146
+ }
147
+
148
+ # ══════════════════════════════════════════
149
+ # next-sprint — Auto-advance to next sprint
150
+ # Checks if current sprint is complete, loads next sprint features
151
+ # ══════════════════════════════════════════
152
+ cmd_next_sprint() {
153
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first."; exit 1; fi
154
+ if [ ! -f "$FEATURES" ]; then echo "[queue] feature-list.json not found."; exit 1; fi
155
+
156
+ acquire_queue_lock
157
+
158
+ local ready in_prog failed
159
+ ready=$(jq '.queue.ready | length' "$QUEUE" 2>/dev/null || echo 0)
160
+ in_prog=$(jq '.queue.in_progress | length' "$QUEUE" 2>/dev/null || echo 0)
161
+ failed=$(jq '.queue.failed | length' "$QUEUE" 2>/dev/null || echo 0)
162
+
163
+ # Current sprint still has work
164
+ if [ "$ready" -gt 0 ] || [ "$in_prog" -gt 0 ]; then
165
+ release_queue_lock
166
+ echo "[queue] Current sprint still active: $ready ready, $in_prog in progress"
167
+ return 1
168
+ fi
169
+
170
+ # Failed features block advancement
171
+ if [ "$failed" -gt 0 ]; then
172
+ release_queue_lock
173
+ echo "[queue] Cannot advance: $failed failed features. Requeue or fix them first."
174
+ return 1
175
+ fi
176
+
177
+ # Find current sprint number
178
+ local current_sprint
179
+ current_sprint=$(jq -r '.current_sprint // 0' "$QUEUE" 2>/dev/null)
180
+ if [ "$current_sprint" = "null" ] || [ "$current_sprint" = "0" ]; then
181
+ # Detect from passed features
182
+ current_sprint=$(jq -r --slurpfile q "$QUEUE" '
183
+ [.features[] | select(.id as $fid | $q[0].queue.passed | index($fid)) | .sprint // 1] | max // 1
184
+ ' "$FEATURES" 2>/dev/null)
185
+ fi
186
+
187
+ local next_sprint=$((current_sprint + 1))
188
+
189
+ # Check if next sprint features exist
190
+ local next_count
191
+ next_count=$(jq --arg s "$next_sprint" '[.features[] | select((.sprint // 1) == ($s | tonumber))] | length' "$FEATURES" 2>/dev/null)
192
+
193
+ if [ "$next_count" -eq 0 ]; then
194
+ release_queue_lock
195
+ echo "[queue] ALL SPRINTS COMPLETE. No Sprint $next_sprint features found."
196
+ echo "[queue] Total passed: $(jq '.queue.passed | length' "$QUEUE")"
197
+ return 0
198
+ fi
199
+
200
+ release_queue_lock
201
+
202
+ # Re-init with next sprint (preserves passed from previous sprints)
203
+ echo "[queue] Sprint $current_sprint complete! Advancing to Sprint $next_sprint ($next_count features)"
204
+ cmd_init "$next_sprint"
205
+ }
206
+
207
+ # ══════════════════════════════════════════
208
+ # dequeue — Assign next ready feature to team
209
+ # ══════════════════════════════════════════
210
+ cmd_dequeue() {
211
+ local team_id="${1:-}"
212
+ if [ -z "$team_id" ]; then echo "[queue] Usage: dequeue <team_id>"; exit 1; fi
213
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first."; exit 1; fi
214
+
215
+ acquire_queue_lock
216
+
217
+ local feature
218
+ feature=$(jq -r '.queue.ready[0] // empty' "$QUEUE")
219
+
220
+ if [ -z "$feature" ]; then
221
+ release_queue_lock
222
+ echo "[queue] No features in ready queue."
223
+ local in_prog blocked
224
+ in_prog=$(jq '.queue.in_progress | length' "$QUEUE")
225
+ blocked=$(jq '.queue.blocked | length' "$QUEUE")
226
+ if [ "$in_prog" -eq 0 ] && [ "$blocked" -eq 0 ]; then
227
+ echo "[queue] ALL FEATURES COMPLETE."
228
+ fi
229
+ return 1
230
+ fi
231
+
232
+ jq --arg fid "$feature" --arg tid "$team_id" '
233
+ .queue.ready -= [$fid] |
234
+ .queue.in_progress[$fid] = { team: ($tid | tonumber), phase: "gen", attempt: 1 } |
235
+ .teams[$tid] = { status: "busy", feature: $fid, branch: ("feature/" + $fid), pid: null }
236
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
237
+
238
+ release_queue_lock
239
+ echo "$feature"
240
+ }
241
+
242
+ # ══════════════════════════════════════════
243
+ # pass — Mark feature as passed, unblock dependents
244
+ # ══════════════════════════════════════════
245
+ cmd_pass() {
246
+ local fid="${1:-}"
247
+ if [ -z "$fid" ]; then echo "[queue] Usage: pass <feature_id>"; exit 1; fi
248
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first."; exit 1; fi
249
+
250
+ acquire_queue_lock
251
+
252
+ local team_id
253
+ team_id=$(jq -r --arg fid "$fid" '.queue.in_progress[$fid].team // empty' "$QUEUE")
254
+
255
+ jq --arg fid "$fid" --arg tid "${team_id:-0}" '
256
+ # Remove from in_progress
257
+ del(.queue.in_progress[$fid]) |
258
+
259
+ # Add to passed
260
+ .queue.passed += [$fid] |
261
+ .queue.passed |= unique |
262
+
263
+ # Free team
264
+ (if $tid != "0" then
265
+ .teams[$tid] = { status: "idle", feature: null, branch: null, pid: null }
266
+ else . end) |
267
+
268
+ # Unblock dependents: for each blocked feature, remove $fid from its deps
269
+ # If deps become empty, move to ready
270
+ .queue.blocked as $blocked |
271
+ reduce ($blocked | keys[]) as $blocked_fid (
272
+ .;
273
+ .queue.blocked[$blocked_fid] -= [$fid] |
274
+ if (.queue.blocked[$blocked_fid] | length) == 0 then
275
+ del(.queue.blocked[$blocked_fid]) |
276
+ .queue.ready += [$blocked_fid]
277
+ else . end
278
+ )
279
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
280
+
281
+ local newly_ready
282
+ newly_ready=$(jq -r '.queue.ready | join(", ")' "$QUEUE")
283
+ release_queue_lock
284
+ echo "[queue] $fid PASSED. Ready: [$newly_ready]"
285
+ }
286
+
287
+ # ══════════════════════════════════════════
288
+ # fail — Mark feature as failed
289
+ # ══════════════════════════════════════════
290
+ cmd_fail() {
291
+ local fid="${1:-}"
292
+ if [ -z "$fid" ]; then echo "[queue] Usage: fail <feature_id>"; exit 1; fi
293
+ if [ ! -f "$QUEUE" ]; then exit 1; fi
294
+
295
+ acquire_queue_lock
296
+
297
+ local team_id
298
+ team_id=$(jq -r --arg fid "$fid" '.queue.in_progress[$fid].team // empty' "$QUEUE")
299
+
300
+ jq --arg fid "$fid" --arg tid "${team_id:-0}" '
301
+ del(.queue.in_progress[$fid]) |
302
+ .queue.failed += [$fid] |
303
+ .queue.failed |= unique |
304
+ (if $tid != "0" then
305
+ .teams[$tid] = { status: "idle", feature: null, branch: null, pid: null }
306
+ else . end)
307
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
308
+
309
+ release_queue_lock
310
+ echo "[queue] $fid FAILED."
311
+ }
312
+
313
+ # ══════════════════════════════════════════
314
+ # requeue — Move failed feature back to ready
315
+ # ══════════════════════════════════════════
316
+ cmd_requeue() {
317
+ local fid="${1:-}"
318
+ if [ -z "$fid" ]; then echo "[queue] Usage: requeue <feature_id>"; exit 1; fi
319
+
320
+ acquire_queue_lock
321
+
322
+ jq --arg fid "$fid" '
323
+ .queue.failed -= [$fid] |
324
+ .queue.ready += [$fid]
325
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
326
+
327
+ release_queue_lock
328
+ echo "[queue] $fid requeued to ready."
329
+ }
330
+
331
+ # ══════════════════════════════════════════
332
+ # update_phase — Update in_progress feature phase/attempt
333
+ # ══════════════════════════════════════════
334
+ cmd_update_phase() {
335
+ local fid="${1:-}" phase="${2:-}" attempt="${3:-}"
336
+ if [ -z "$fid" ] || [ -z "$phase" ]; then
337
+ echo "[queue] Usage: update_phase <feature_id> <phase> [attempt]"
338
+ exit 1
339
+ fi
340
+
341
+ acquire_queue_lock
342
+
343
+ local jq_expr
344
+ jq_expr=".queue.in_progress[\"$fid\"].phase = \"$phase\""
345
+ if [ -n "$attempt" ]; then
346
+ jq_expr="$jq_expr | .queue.in_progress[\"$fid\"].attempt = ($attempt | tonumber)"
347
+ fi
348
+
349
+ jq "$jq_expr" "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
350
+ release_queue_lock
351
+ }
352
+
353
+ # ══════════════════════════════════════════
354
+ # status — Print queue state
355
+ # ══════════════════════════════════════════
356
+ cmd_status() {
357
+ if [ ! -f "$QUEUE" ]; then
358
+ echo "[queue] Not initialized. Run: bash scripts/harness-queue-manager.sh init"
359
+ return
360
+ fi
361
+
362
+ local ready blocked in_prog passed failed
363
+ ready=$(jq -r '.queue.ready | length' "$QUEUE")
364
+ blocked=$(jq -r '.queue.blocked | length' "$QUEUE")
365
+ in_prog=$(jq -r '.queue.in_progress | length' "$QUEUE")
366
+ passed=$(jq -r '.queue.passed | length' "$QUEUE")
367
+ failed=$(jq -r '.queue.failed | length' "$QUEUE")
368
+ local total=$((ready + blocked + in_prog + passed + failed))
369
+
370
+ echo ""
371
+ echo " Feature Queue ($passed/$total done)"
372
+ echo " ─────────────────────────────"
373
+ echo " Ready: $ready"
374
+ echo " Blocked: $blocked"
375
+ echo " In Progress: $in_prog"
376
+ echo " Passed: $passed"
377
+ echo " Failed: $failed"
378
+ echo ""
379
+
380
+ # Team status
381
+ local team_count
382
+ team_count=$(jq '.teams | length' "$QUEUE")
383
+ echo " Teams"
384
+ echo " ─────────────────────────────"
385
+ for i in $(seq 1 "$team_count"); do
386
+ local t_status t_feature
387
+ t_status=$(jq -r ".teams[\"$i\"].status // \"idle\"" "$QUEUE")
388
+ t_feature=$(jq -r ".teams[\"$i\"].feature // \"—\"" "$QUEUE")
389
+ printf " Team %d: %-6s %s\n" "$i" "$t_status" "$t_feature"
390
+ done
391
+ echo ""
392
+
393
+ # In-progress details
394
+ if [ "$in_prog" -gt 0 ]; then
395
+ echo " In Progress"
396
+ echo " ─────────────────────────────"
397
+ jq -r '.queue.in_progress | to_entries[] | " \(.key): team \(.value.team) — \(.value.phase) (attempt \(.value.attempt))"' "$QUEUE"
398
+ echo ""
399
+ fi
400
+ }
401
+
402
+ # ══════════════════════════════════════════
403
+ # recover — Move stale in_progress back to ready (after studio restart)
404
+ # ══════════════════════════════════════════
405
+ cmd_recover() {
406
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Not initialized."; return; fi
407
+
408
+ local stale_count
409
+ stale_count=$(jq '.queue.in_progress | length' "$QUEUE" 2>/dev/null)
410
+
411
+ if [ "${stale_count:-0}" -eq 0 ]; then
412
+ echo "[queue] No stale in_progress entries."
413
+ return
414
+ fi
415
+
416
+ # Move all in_progress → ready, reset all teams to idle
417
+ jq '
418
+ .queue.ready += [.queue.in_progress | keys[]] |
419
+ .queue.ready |= unique |
420
+ .queue.in_progress = {} |
421
+ .teams |= with_entries(.value = { status: "idle", feature: null, branch: null, pid: null })
422
+ ' "$QUEUE" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
423
+
424
+ echo "[queue] Recovered ${stale_count} stale features back to ready queue."
425
+ }
426
+
427
+ # ══════════════════════════════════════════
428
+ # auto-dispatch — Pair all idle teams with ready features in one atomic step.
429
+ # Output: JSON array of {team, feature} on stdout.
430
+ # Atomically moves ready → in_progress + teams[tid] = busy for each pair.
431
+ # Dependencies are already honored (ready queue only holds unblocked features).
432
+ # ══════════════════════════════════════════
433
+ cmd_auto_dispatch() {
434
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first." >&2; exit 1; fi
435
+
436
+ acquire_queue_lock
437
+
438
+ local result
439
+ result=$(jq '
440
+ . as $root |
441
+ ($root.teams | to_entries | map(select(.value.status == "idle")) | map(.key)) as $idle |
442
+ ($root.queue.ready) as $ready |
443
+ (if ($idle | length) < ($ready | length) then ($idle | length) else ($ready | length) end) as $n |
444
+ [range(0; $n) | { team: ($idle[.] | tonumber), feature: $ready[.] }] as $pairs |
445
+ (reduce $pairs[] as $p ($root;
446
+ .queue.ready -= [$p.feature] |
447
+ .queue.in_progress[$p.feature] = { team: $p.team, phase: "gen", attempt: 1 } |
448
+ .teams[($p.team | tostring)] = {
449
+ status: "busy",
450
+ feature: $p.feature,
451
+ branch: ("feature/" + $p.feature),
452
+ pid: null
453
+ }
454
+ )) as $updated |
455
+ { state: $updated, pairs: $pairs }
456
+ ' "$QUEUE")
457
+
458
+ local new_state pairs
459
+ new_state=$(echo "$result" | jq '.state')
460
+ pairs=$(echo "$result" | jq -c '.pairs')
461
+
462
+ # Write updated state
463
+ echo "$new_state" > "${QUEUE}.tmp" && mv "${QUEUE}.tmp" "$QUEUE"
464
+
465
+ release_queue_lock
466
+
467
+ # Emit pairs (single JSON line) for caller
468
+ echo "$pairs"
469
+ }
470
+
471
+ # ══════════════════════════════════════════
472
+ # idle-slots — Report how many teams are idle + how many ready features wait.
473
+ # No state mutation. Useful for dashboards / quick checks.
474
+ # ══════════════════════════════════════════
475
+ cmd_idle_slots() {
476
+ if [ ! -f "$QUEUE" ]; then echo "[queue] Run 'init' first." >&2; exit 1; fi
477
+ jq '{
478
+ idle_teams: [.teams | to_entries[] | select(.value.status == "idle") | .key],
479
+ ready_features: .queue.ready,
480
+ dispatchable: (
481
+ (if ([.teams | to_entries[] | select(.value.status == "idle") | .key] | length) <
482
+ (.queue.ready | length)
483
+ then [.teams | to_entries[] | select(.value.status == "idle") | .key] | length
484
+ else .queue.ready | length
485
+ end)
486
+ )
487
+ }' "$QUEUE"
488
+ }
489
+
490
+ # ── Rate-Limit Hold checkpoint (v5.6.7+) ──
491
+ CHECKPOINT="$PROJECT_ROOT/.harness/actions/team-checkpoint.json"
492
+
493
+ cmd_hold() {
494
+ # Args: <reason> [retry_seconds=600]
495
+ local reason="${1:-rate_limit}"
496
+ local retry_secs="${2:-600}"
497
+ local now_iso retry_iso
498
+ now_iso="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
499
+ retry_iso="$(date -u -v+"${retry_secs}"S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
500
+ || date -u -d "+${retry_secs} seconds" +%Y-%m-%dT%H:%M:%SZ)"
501
+ acquire_queue_lock
502
+ trap release_queue_lock EXIT
503
+ local in_progress pending
504
+ in_progress="$(jq '[.teams[] | select(.current_feature != null) |
505
+ {team: .team_id, feature: .current_feature, phase: (.current_phase // "gen")}]' "$QUEUE")"
506
+ pending="$(jq '[.features[] | select(.status == "ready") | .id]' "$QUEUE")"
507
+ local tmp="${CHECKPOINT}.tmp"
508
+ jq -n --arg ts "$now_iso" --arg retry "$retry_iso" --arg reason "$reason" \
509
+ --argjson inprog "$in_progress" --argjson ready "$pending" \
510
+ '{timestamp:$ts, retry_at:$retry, last_error:$reason,
511
+ in_progress:$inprog, ready_features:$ready,
512
+ hold_count: 1, escalate_after: 72}' > "$tmp" && mv "$tmp" "$CHECKPOINT"
513
+ release_queue_lock
514
+ trap - EXIT
515
+ echo "[hold] checkpoint written — retry_at=$retry_iso reason=$reason"
516
+ }
517
+
518
+ cmd_resume_probe() {
519
+ # Lightweight probe — called on ScheduleWakeup resume.
520
+ # Exits 0 if ready to resume, 1 if still held, 2 if escalated.
521
+ [ ! -f "$CHECKPOINT" ] && { echo "[resume] no checkpoint — nothing to resume"; exit 0; }
522
+ local hold_count escalate_after
523
+ hold_count="$(jq -r '.hold_count // 1' "$CHECKPOINT")"
524
+ escalate_after="$(jq -r '.escalate_after // 72' "$CHECKPOINT")"
525
+ if [ "$hold_count" -ge "$escalate_after" ]; then
526
+ echo "[resume] ESCALATED — held $hold_count cycles (max=$escalate_after). Manual intervention required."
527
+ exit 2
528
+ fi
529
+ # Probe: minimal claude call — if rate-limited, exits non-zero quickly.
530
+ if command -v claude >/dev/null 2>&1; then
531
+ if echo "ping" | timeout 30 claude -p "reply only: pong" >/dev/null 2>&1; then
532
+ echo "[resume] probe OK — clearing hold"
533
+ rm -f "$CHECKPOINT"
534
+ exit 0
535
+ fi
536
+ fi
537
+ # Still held — increment and report
538
+ local tmp="${CHECKPOINT}.tmp"
539
+ jq '.hold_count = (.hold_count + 1) | .last_probe_at = now | todate' "$CHECKPOINT" > "$tmp" && mv "$tmp" "$CHECKPOINT"
540
+ echo "[resume] still held (cycle $((hold_count+1))/$escalate_after) — schedule another wake-up"
541
+ exit 1
542
+ }
543
+
544
+ # ══════════════════════════════════════════
545
+ # enqueue — Append feature to feature-list.json AND insert into queue (canonical path).
546
+ #
547
+ # Lead 가 ad-hoc 핫픽스 / 새 sprint feature 를 추가할 때 jq 로 큐를 직접 편집하면
548
+ # feature-list.json 과 큐가 갈라진다 (실제 사고: F-716, F-800~F-806 이 큐에만 등록).
549
+ # 이 명령은 feature-list append → queue insert (deps 미해소면 blocked) 를 한 트랜잭션으로 처리.
550
+ #
551
+ # Usage: enqueue <feature-json-file> # 파일 경로
552
+ # enqueue - # stdin 으로 JSON
553
+ #
554
+ # 입력 JSON 최소 필드: {id, title, sprint, depends_on?, layer?, service?, acceptance_criteria?}
555
+ # ══════════════════════════════════════════
556
+ cmd_enqueue() {
557
+ local input="${1:-}"
558
+ if [ -z "$input" ]; then
559
+ echo "[queue] enqueue: feature JSON 파일 경로 또는 '-' (stdin) 필요" >&2
560
+ exit 1
561
+ fi
562
+ if [ ! -f "$FEATURES" ]; then echo "[queue] feature-list.json not found." >&2; exit 1; fi
563
+ if [ ! -f "$QUEUE" ]; then echo "[queue] feature-queue.json not found. init 먼저." >&2; exit 1; fi
564
+
565
+ local feat_json
566
+ if [ "$input" = "-" ]; then
567
+ feat_json=$(cat)
568
+ else
569
+ feat_json=$(cat "$input")
570
+ fi
571
+
572
+ if ! echo "$feat_json" | jq -e '.id and .title and .sprint' >/dev/null 2>&1; then
573
+ echo "[queue] enqueue: id/title/sprint 필수 필드 누락" >&2
574
+ exit 1
575
+ fi
576
+
577
+ local fid
578
+ fid=$(echo "$feat_json" | jq -r '.id')
579
+
580
+ acquire_queue_lock
581
+
582
+ if jq -e --arg id "$fid" '.features[] | select(.id == $id)' "$FEATURES" >/dev/null 2>&1; then
583
+ release_queue_lock
584
+ echo "[queue] enqueue: $fid 이 feature-list.json 에 이미 존재" >&2
585
+ exit 1
586
+ fi
587
+
588
+ if jq -e --arg id "$fid" '
589
+ ($id | IN(.queue.ready[]?,.queue.passed[]?)) or
590
+ (.queue.in_progress[$id]?) or (.queue.blocked[$id]?) or
591
+ (.queue.failed[$id]? // (.queue.failed | type == "array" and ($id | IN(.queue.failed[]?))))
592
+ ' "$QUEUE" >/dev/null 2>&1; then
593
+ release_queue_lock
594
+ echo "[queue] enqueue: $fid 이 큐에 이미 존재" >&2
595
+ exit 1
596
+ fi
597
+
598
+ local tmp_features="${FEATURES}.tmp.$$"
599
+ if ! jq --argjson new "$feat_json" '.features += [$new]' "$FEATURES" > "$tmp_features" 2>/dev/null; then
600
+ rm -f "$tmp_features"; release_queue_lock
601
+ echo "[queue] enqueue: feature-list.json 업데이트 실패" >&2; exit 1
602
+ fi
603
+ mv "$tmp_features" "$FEATURES"
604
+
605
+ local deps unmet
606
+ deps=$(echo "$feat_json" | jq -c '.depends_on // []')
607
+ unmet=$(jq --argjson d "$deps" '
608
+ ($d - (.queue.passed // [])) | length
609
+ ' "$QUEUE" 2>/dev/null || echo 0)
610
+
611
+ local tmp_queue="${QUEUE}.tmp.$$"
612
+ if [ "${unmet:-0}" -gt 0 ]; then
613
+ jq --arg id "$fid" --argjson deps "$deps" '.queue.blocked[$id] = $deps' "$QUEUE" > "$tmp_queue" 2>/dev/null
614
+ else
615
+ jq --arg id "$fid" '.queue.ready += [$id]' "$QUEUE" > "$tmp_queue" 2>/dev/null
616
+ fi
617
+ if [ ! -s "$tmp_queue" ]; then
618
+ rm -f "$tmp_queue"; release_queue_lock
619
+ echo "[queue] enqueue: feature-queue.json 업데이트 실패" >&2; exit 1
620
+ fi
621
+ mv "$tmp_queue" "$QUEUE"
622
+
623
+ release_queue_lock
624
+
625
+ if [ "${unmet:-0}" -gt 0 ]; then
626
+ echo "[queue] enqueue: $fid → blocked (unmet deps: ${unmet})"
627
+ else
628
+ echo "[queue] enqueue: $fid → ready"
629
+ fi
630
+ }
631
+
632
+ # ══════════════════════════════════════════
633
+ # integrity — feature-list ↔ queue divergence 검사.
634
+ # 큐에 있지만 feature-list 에 없는 feature ID 를 출력. 0건이면 종료코드 0.
635
+ # ══════════════════════════════════════════
636
+ cmd_integrity() {
637
+ if [ ! -f "$FEATURES" ] || [ ! -f "$QUEUE" ]; then
638
+ echo "[integrity] feature-list 또는 feature-queue 없음"
639
+ exit 0
640
+ fi
641
+ local orphans
642
+ orphans=$(jq -r --slurpfile q "$QUEUE" '
643
+ ([.features[].id]) as $fl |
644
+ (($q[0].queue.ready // []) +
645
+ ($q[0].queue.passed // []) +
646
+ (($q[0].queue.failed // []) | if type == "object" then keys else . end) +
647
+ (($q[0].queue.in_progress // {}) | keys) +
648
+ (($q[0].queue.blocked // {}) | keys)) as $qids |
649
+ ($qids - $fl) | unique | .[]
650
+ ' "$FEATURES" 2>/dev/null)
651
+
652
+ if [ -z "$orphans" ]; then
653
+ echo "[integrity] OK — feature-list ↔ queue 동기화됨"
654
+ exit 0
655
+ fi
656
+ echo "[integrity] ⚠ feature-list 에 없는 queue-only feature 발견:"
657
+ echo "$orphans" | sed 's/^/ - /'
658
+ echo "[integrity] 복구 방법:"
659
+ echo " 1) Planner 가 해당 feature 를 feature-list.json 에 sprint 지정해서 추가하거나"
660
+ echo " 2) bash scripts/harness-queue-manager.sh enqueue <feature.json> 로 canonical path 사용"
661
+ exit 1
662
+ }
663
+
664
+ cmd_hold_status() {
665
+ if [ ! -f "$CHECKPOINT" ]; then
666
+ echo '{"held":false}'
667
+ return
668
+ fi
669
+ jq '. + {held:true}' "$CHECKPOINT"
670
+ }
671
+
672
+ # ── Dispatch ──
673
+ case "$CMD" in
674
+ init) cmd_init "$@" ;;
675
+ dequeue) cmd_dequeue "$@" ;;
676
+ auto-dispatch) cmd_auto_dispatch ;;
677
+ idle-slots) cmd_idle_slots ;;
678
+ pass) cmd_pass "$@" ;;
679
+ fail) cmd_fail "$@" ;;
680
+ requeue) cmd_requeue "$@" ;;
681
+ update_phase) cmd_update_phase "$@" ;;
682
+ recover) cmd_recover ;;
683
+ next-sprint) cmd_next_sprint ;;
684
+ status) cmd_status ;;
685
+ hold) cmd_hold "$@" ;;
686
+ resume-probe) cmd_resume_probe ;;
687
+ hold-status) cmd_hold_status ;;
688
+ enqueue) cmd_enqueue "$@" ;;
689
+ integrity) cmd_integrity ;;
690
+ *)
691
+ echo "Usage: harness-queue-manager.sh <init|dequeue|auto-dispatch|idle-slots|pass|fail|requeue|recover|next-sprint|update_phase|status|hold|resume-probe|hold-status|enqueue|integrity> [args]"
692
+ exit 1
693
+ ;;
694
+ esac