@zachjxyz/moxie 0.2.4 → 0.3.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.
package/lib/phases.sh CHANGED
@@ -52,13 +52,13 @@ _discover_context_docs() {
52
52
  echo "$rp"
53
53
  done < "$tmpfile" | sort -u > "$dedup_file"
54
54
 
55
- # Sort by mtime (most recent first) — macOS stat
55
+ # Sort by mtime (most recent first)
56
56
  local sorted_file
57
57
  sorted_file=$(mktemp)
58
58
 
59
59
  while IFS= read -r rp; do
60
60
  local mtime
61
- mtime=$(stat -f "%m" "$rp" 2>/dev/null || echo "0")
61
+ mtime=$(stat_mtime "$rp")
62
62
  echo "$mtime $rp"
63
63
  done < "$dedup_file" | sort -rn | head -10 | while IFS= read -r line; do
64
64
  echo "${line#* }"
@@ -97,7 +97,7 @@ _select_context_docs() {
97
97
  else
98
98
  sz="${sz} B"
99
99
  fi
100
- mdate=$(stat -f "%Sm" -t "%b %d" "$f" 2>/dev/null || echo "unknown")
100
+ mdate=$(stat_date "$f")
101
101
  meta+=("${sz}, ${mdate}")
102
102
  local len=${#f}
103
103
  [ "$len" -gt "$max_label_len" ] && max_label_len=$len
@@ -209,6 +209,272 @@ _select_context_docs() {
209
209
  done
210
210
  }
211
211
 
212
+ # ---- Agent selection TUI ----
213
+ # Populates SELECTED_AGENT_INDICES (CLI) and SELECTED_GATEWAY_INDICES (gateway).
214
+ # Shows CLI agents found on PATH + gateway models. Requires minimum 2 total.
215
+
216
+ _select_agents() {
217
+ SELECTED_AGENT_INDICES=()
218
+ SELECTED_GATEWAY_INDICES=()
219
+
220
+ # Build unified item list: CLI agents, separator, gateway models
221
+ local item_labels=()
222
+ local item_meta=()
223
+ local item_types=() # "cli", "separator", "gateway"
224
+ local item_sources=() # index into KNOWN_AGENT_* or KNOWN_GATEWAY_*
225
+ local max_label_len=0
226
+
227
+ # CLI agents header
228
+ item_labels+=("--- Detected CLI agents ---")
229
+ item_meta+=("")
230
+ item_types+=("separator")
231
+ item_sources+=("-1")
232
+
233
+ # CLI agents on PATH
234
+ for idx in "${AVAILABLE_AGENT_INDICES[@]}"; do
235
+ local label="${KNOWN_AGENT_LABELS[$idx]}"
236
+ local binary="${KNOWN_AGENT_BINARIES[$idx]}"
237
+ item_labels+=("$label")
238
+ local ver
239
+ ver=$("$binary" --version 2>&1 | head -1 | grep -oE '[0-9]+\.[0-9]+[0-9.]*' | head -1) || ver=""
240
+ [ -z "$ver" ] && ver="installed"
241
+ item_meta+=("$ver")
242
+ item_types+=("cli")
243
+ item_sources+=("$idx")
244
+ local len=${#name}
245
+ [ "$len" -gt "$max_label_len" ] && max_label_len=$len
246
+ done
247
+
248
+ # Separator
249
+ item_labels+=("--- Vercel AI Gateway ---")
250
+ item_meta+=("")
251
+ item_types+=("separator")
252
+ item_sources+=("-1")
253
+
254
+ # Gateway models (always shown)
255
+ for idx in "${!KNOWN_GATEWAY_NAMES[@]}"; do
256
+ local label="${KNOWN_GATEWAY_LABELS[$idx]}"
257
+ item_labels+=("$label")
258
+ item_meta+=("${KNOWN_GATEWAY_MODELS[$idx]}")
259
+ item_types+=("gateway")
260
+ item_sources+=("$idx")
261
+ local len=${#label}
262
+ [ "$len" -gt "$max_label_len" ] && max_label_len=$len
263
+ done
264
+
265
+ local count=${#item_labels[@]}
266
+
267
+ # State: pre-select CLI agents, gateway unselected
268
+ local cursor=0
269
+ local selected=()
270
+ for (( i = 0; i < count; i++ )); do
271
+ if [ "${item_types[$i]}" = "cli" ]; then
272
+ selected+=(1)
273
+ else
274
+ selected+=(0)
275
+ fi
276
+ done
277
+
278
+ # Skip separators for initial cursor position
279
+ while [ "$cursor" -lt "$count" ] && [ "${item_types[$cursor]}" = "separator" ]; do
280
+ cursor=$((cursor + 1))
281
+ done
282
+
283
+ local sep=""
284
+ for (( i = 0; i < max_label_len + 40; i++ )); do sep="${sep}-"; done
285
+
286
+ # submit row is at index $count
287
+ local jump_back=$(( count + 3 ))
288
+
289
+ _agent_render() {
290
+ local sel_count=0
291
+ for (( i = 0; i < count; i++ )); do
292
+ [ "${selected[$i]}" = "1" ] && sel_count=$(( sel_count + 1 ))
293
+ done
294
+
295
+ for (( i = 0; i < count; i++ )); do
296
+ if [ "${item_types[$i]}" = "separator" ]; then
297
+ printf "\\r\\033[K \\033[2m%s\\033[0m\\n" "${item_labels[$i]}" >&2
298
+ continue
299
+ fi
300
+ local marker=" "
301
+ [ "$cursor" -eq "$i" ] && marker="> "
302
+ local check="[ ]"
303
+ [ "${selected[$i]}" = "1" ] && check="[x]"
304
+ if [ -n "${item_meta[$i]}" ]; then
305
+ printf "\\r\\033[K %s%s %-${max_label_len}s (%s)\\n" "$marker" "$check" "${item_labels[$i]}" "${item_meta[$i]}" >&2
306
+ else
307
+ printf "\\r\\033[K %s%s %s\\n" "$marker" "$check" "${item_labels[$i]}" >&2
308
+ fi
309
+ done
310
+
311
+ printf "\\r\\033[K %s\\n" "$sep" >&2
312
+
313
+ local submit_marker=" "
314
+ [ "$cursor" -eq "$count" ] && submit_marker="> "
315
+ if [ "$sel_count" -ge 2 ]; then
316
+ printf "\\r\\033[K %s%s\\n" "$submit_marker" "Submit ($sel_count selected)" >&2
317
+ else
318
+ printf "\\r\\033[K %s\\033[2m%s\\033[0m\\n" "$submit_marker" "Submit (need at least 2)" >&2
319
+ fi
320
+
321
+ printf "\\r\\033[K\\n\\033[K \\033[2m↑↓ navigate · space/enter toggle · enter submit (min 2)\\033[0m" >&2
322
+ }
323
+
324
+ printf "Select agents (at least 2). CLI agents pre-selected, gateway models available below:\\n\\n" >&2
325
+ printf "\\033[?25l" >&2
326
+ _agent_render
327
+
328
+ local old_stty
329
+ old_stty=$(stty -g < /dev/tty 2>/dev/null)
330
+ _agent_cleanup() {
331
+ printf "\\033[?25h" >&2
332
+ stty "$old_stty" < /dev/tty 2>/dev/null
333
+ }
334
+ trap '_agent_cleanup' INT TERM
335
+ stty -echo -icanon min 1 < /dev/tty 2>/dev/null
336
+
337
+ while true; do
338
+ local key
339
+ key=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || break
340
+
341
+ if [ "$key" = $'\033' ]; then
342
+ local bracket dir
343
+ bracket=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
344
+ dir=$(dd bs=1 count=1 2>/dev/null < /dev/tty) || true
345
+ if [ "$bracket" = "[" ]; then
346
+ case "$dir" in
347
+ A) # Up: skip separators
348
+ local new_cursor=$((cursor - 1))
349
+ while [ "$new_cursor" -ge 0 ] && [ "${item_types[$new_cursor]}" = "separator" ]; do
350
+ new_cursor=$((new_cursor - 1))
351
+ done
352
+ [ "$new_cursor" -ge 0 ] && cursor=$new_cursor
353
+ ;;
354
+ B) # Down: skip separators
355
+ local new_cursor=$((cursor + 1))
356
+ while [ "$new_cursor" -lt "$count" ] && [ "${item_types[$new_cursor]}" = "separator" ]; do
357
+ new_cursor=$((new_cursor + 1))
358
+ done
359
+ [ "$new_cursor" -le "$count" ] && cursor=$new_cursor
360
+ ;;
361
+ esac
362
+ fi
363
+ elif [ "$key" = " " ]; then
364
+ if [ "$cursor" -lt "$count" ] && [ "${item_types[$cursor]}" != "separator" ]; then
365
+ [ "${selected[$cursor]}" = "0" ] && selected[$cursor]=1 || selected[$cursor]=0
366
+ fi
367
+ elif [ "$key" = "" ]; then
368
+ if [ "$cursor" -eq "$count" ]; then
369
+ local sel_count=0
370
+ for (( i = 0; i < count; i++ )); do
371
+ [ "${selected[$i]}" = "1" ] && sel_count=$(( sel_count + 1 ))
372
+ done
373
+ [ "$sel_count" -ge 2 ] && break
374
+ elif [ "${item_types[$cursor]}" != "separator" ]; then
375
+ [ "${selected[$cursor]}" = "0" ] && selected[$cursor]=1 || selected[$cursor]=0
376
+ fi
377
+ elif [ "$key" = "q" ]; then
378
+ for (( i = 0; i < count; i++ )); do selected[$i]=0; done
379
+ break
380
+ fi
381
+
382
+ printf "\\033[%dA" "$jump_back" >&2
383
+ _agent_render
384
+ done
385
+
386
+ _agent_cleanup
387
+ trap - INT TERM
388
+ printf "\\n\\n" >&2
389
+
390
+ # Collect selections by type
391
+ local has_gateway=0
392
+ for (( i = 0; i < count; i++ )); do
393
+ [ "${selected[$i]}" != "1" ] && continue
394
+ case "${item_types[$i]}" in
395
+ cli) SELECTED_AGENT_INDICES+=("${item_sources[$i]}") ;;
396
+ gateway)
397
+ SELECTED_GATEWAY_INDICES+=("${item_sources[$i]}")
398
+ has_gateway=1
399
+ ;;
400
+ esac
401
+ done
402
+
403
+ # If gateway models selected, ensure API key is stored
404
+ if [ "$has_gateway" = "1" ] && ! gateway_has_key "vercel-ai-gateway"; then
405
+ printf "Gateway models selected. Setting up API key...\\n\\n" >&2
406
+ gateway_store_key "vercel-ai-gateway" || {
407
+ echo "ERROR: Failed to store gateway key. Gateway models will not work." >&2
408
+ SELECTED_GATEWAY_INDICES=()
409
+ }
410
+ fi
411
+ }
412
+
413
+ # ---- Write config.toml from selected agents ----
414
+
415
+ _write_config_toml() {
416
+ local config_file="$1"
417
+ cat > "$config_file" <<'HEADER'
418
+ # moxie configuration
419
+ # Generated by: moxie init
420
+
421
+ [spec]
422
+ path = "spec.md"
423
+
424
+ HEADER
425
+
426
+ # Gateway section (if any gateway models selected)
427
+ if [ ${#SELECTED_GATEWAY_INDICES[@]} -gt 0 ]; then
428
+ cat >> "$config_file" <<'GATEWAY'
429
+ [gateway]
430
+ endpoint = "https://ai-gateway.vercel.sh"
431
+
432
+ GATEWAY
433
+ fi
434
+
435
+ cat >> "$config_file" <<'AGENTHEAD'
436
+ # Agent definitions — order is the default rotation sequence.
437
+ # Rotation is randomized per phase at runtime, so order only
438
+ # determines fallback ordering.
439
+ AGENTHEAD
440
+
441
+ local order=1
442
+
443
+ # CLI agents
444
+ for idx in "${SELECTED_AGENT_INDICES[@]}"; do
445
+ local name="${KNOWN_AGENT_NAMES[$idx]}"
446
+ local cmd="${KNOWN_AGENT_CMDS[$idx]}"
447
+ cat >> "$config_file" <<AGENT
448
+ [agents.${name}]
449
+ command = '${cmd}'
450
+ order = ${order}
451
+
452
+ AGENT
453
+ order=$((order + 1))
454
+ done
455
+
456
+ # Gateway agents
457
+ for idx in "${SELECTED_GATEWAY_INDICES[@]}"; do
458
+ local name="${KNOWN_GATEWAY_NAMES[$idx]}"
459
+ local model="${KNOWN_GATEWAY_MODELS[$idx]}"
460
+ cat >> "$config_file" <<GWAGENT
461
+ [agents.${name}]
462
+ type = "gateway"
463
+ model = "${model}"
464
+ order = ${order}
465
+
466
+ GWAGENT
467
+ order=$((order + 1))
468
+ done
469
+
470
+ cat >> "$config_file" <<'SETTINGS'
471
+ [settings]
472
+ max_rounds = 15
473
+ max_rounds_build = 50
474
+ turn_timeout = 900
475
+ SETTINGS
476
+ }
477
+
212
478
  _copy_context_docs() {
213
479
  if [ ${#CONTEXT_SELECTED[@]} -eq 0 ]; then
214
480
  return
@@ -273,7 +539,44 @@ cmd_init() {
273
539
  echo "WARNING: $MOXIE_DIR already exists. Reinitializing." >&2
274
540
  fi
275
541
 
276
- # If no --spec provided, RFC phase will generate one
542
+ # ---- Detect and select agents ----
543
+ detect_available_agents
544
+
545
+ if [ "$AVAILABLE_AGENT_COUNT" -lt 2 ]; then
546
+ echo "ERROR: moxie requires at least 2 agent CLIs installed." >&2
547
+ echo "" >&2
548
+ echo "Found $AVAILABLE_AGENT_COUNT agent(s) on PATH. Install at least 2 of:" >&2
549
+ for i in "${!KNOWN_AGENT_NAMES[@]}"; do
550
+ local binary="${KNOWN_AGENT_BINARIES[$i]}"
551
+ if command -v "$binary" &>/dev/null; then
552
+ echo " [OK] ${KNOWN_AGENT_NAMES[$i]}" >&2
553
+ else
554
+ echo " [ ] ${KNOWN_AGENT_NAMES[$i]} (not found)" >&2
555
+ fi
556
+ done
557
+ echo "" >&2
558
+ echo "moxie requires at least 2 agents for cross-model verification." >&2
559
+ echo "Single-agent mode is not supported." >&2
560
+ exit 1
561
+ fi
562
+
563
+ if [ -t 0 ]; then
564
+ _select_agents
565
+ else
566
+ # Non-interactive: auto-select all available CLI agents
567
+ SELECTED_AGENT_INDICES=("${AVAILABLE_AGENT_INDICES[@]}")
568
+ SELECTED_GATEWAY_INDICES=()
569
+ fi
570
+
571
+ local total_selected=$(( ${#SELECTED_AGENT_INDICES[@]} + ${#SELECTED_GATEWAY_INDICES[@]} ))
572
+ if [ "$total_selected" -lt 2 ]; then
573
+ echo "ERROR: At least 2 agents must be selected." >&2
574
+ echo "moxie requires at least 2 agents for cross-model verification." >&2
575
+ echo "Single-agent mode is not supported." >&2
576
+ exit 1
577
+ fi
578
+
579
+ # ---- Spec handling ----
277
580
  if [ -z "$spec_path" ]; then
278
581
  generate_rfc=1
279
582
  echo "Initializing moxie project (RFC will be generated by agents)..."
@@ -285,6 +588,15 @@ cmd_init() {
285
588
  echo "Initializing moxie project..."
286
589
  echo " Spec: $spec_path"
287
590
  fi
591
+
592
+ # List selected agents
593
+ echo " Agents:"
594
+ for idx in "${SELECTED_AGENT_INDICES[@]}"; do
595
+ echo " - ${KNOWN_AGENT_LABELS[$idx]} (CLI)"
596
+ done
597
+ for idx in "${SELECTED_GATEWAY_INDICES[@]}"; do
598
+ echo " - ${KNOWN_GATEWAY_LABELS[$idx]} (${KNOWN_GATEWAY_MODELS[$idx]}, AI Gateway)"
599
+ done
288
600
  echo ""
289
601
 
290
602
  # Create directory structure
@@ -307,35 +619,8 @@ SEED
307
619
  cp "$spec_path" "$MOXIE_DIR/spec.md"
308
620
  fi
309
621
 
310
- # Generate config
311
- cat > "$MOXIE_CONFIG" <<'TOML'
312
- # moxie configuration
313
- # Generated by: moxie init
314
-
315
- [spec]
316
- path = "spec.md"
317
-
318
- # Agent definitions — order is the default rotation sequence.
319
- # Rotation is randomized per phase at runtime, so order only
320
- # determines fallback ordering.
321
- # command is passed the prompt as the final argument.
322
- [agents.codex]
323
- command = 'codex exec -c model_reasoning_effort="xhigh" --dangerously-bypass-approvals-and-sandbox -c model_reasoning_summary="detailed" -c model_supports_reasoning_summaries=true'
324
- order = 1
325
-
326
- [agents.claude]
327
- command = "claude --dangerously-skip-permissions --effort max -p"
328
- order = 2
329
-
330
- [agents.qwen]
331
- command = "qwen --yolo"
332
- order = 3
333
-
334
- [settings]
335
- max_rounds = 15
336
- max_rounds_build = 50
337
- turn_timeout = 900
338
- TOML
622
+ # Generate config from selected agents
623
+ _write_config_toml "$MOXIE_CONFIG"
339
624
 
340
625
  # Generate prompt templates from bundled templates
341
626
  for phase in "${PHASES[@]}"; do
@@ -383,8 +668,8 @@ TOML
383
668
  if [ ! -d "$MOXIE_DIR/context" ] || [ -z "$(ls -A "$MOXIE_DIR/context" 2>/dev/null)" ]; then
384
669
  echo " Tip: add context docs to .moxie/context/ (roadmaps, PRDs, etc.)"
385
670
  fi
386
- echo " 3. Run: moxie start (background, caffeinated)"
387
- echo " Or: moxie run (foreground, caffeinated)"
671
+ echo " 3. Run: moxie start (background)"
672
+ echo " Or: moxie run (foreground)"
388
673
  }
389
674
 
390
675
  _init_ledger() {
@@ -467,6 +752,39 @@ sys.exit(0)
467
752
  " 2>/dev/null
468
753
  }
469
754
 
755
+ # Like _all_agents_reached but excludes degraded agents.
756
+ # Quorum requires all HEALTHY agents to have reached: true.
757
+ _healthy_agents_reached() {
758
+ local ledger="$1"
759
+
760
+ # Build space-separated list of healthy agent names
761
+ local healthy_names=""
762
+ for i in "${!AGENT_NAMES[@]}"; do
763
+ if [ "${AGENT_DEGRADED[$i]}" = "0" ]; then
764
+ healthy_names="${healthy_names}${AGENT_NAMES[$i]} "
765
+ fi
766
+ done
767
+ healthy_names="${healthy_names% }" # trim trailing space
768
+
769
+ local healthy_count
770
+ healthy_count=$(count_healthy_agents)
771
+
772
+ python3 -c "
773
+ import json, sys
774
+ with open('$ledger') as f:
775
+ d = json.load(f)
776
+ agents = d.get('agents', {})
777
+ required = set('$healthy_names'.split())
778
+ if len(required) < 2:
779
+ sys.exit(1)
780
+ for name in required:
781
+ info = agents.get(name, {})
782
+ if not info.get('reached', False):
783
+ sys.exit(1)
784
+ sys.exit(0)
785
+ " 2>/dev/null
786
+ }
787
+
470
788
  # ---- Phase completion detection (for resume) ----
471
789
 
472
790
  _phase_is_complete() {
@@ -496,6 +814,7 @@ _shuffle_agents() {
496
814
  cmd_run() {
497
815
  require_moxie_project
498
816
  load_agents
817
+ check_minimum_agents || exit 1
499
818
 
500
819
  local target_phase=""
501
820
  local dry_run="${DRY_RUN:-0}"
@@ -527,19 +846,34 @@ cmd_run() {
527
846
  echo $$ > "$MOXIE_DIR/moxie.pid"
528
847
  trap 'rm -f "$MOXIE_DIR/moxie.pid"' EXIT
529
848
 
530
- caffeinate -d -i -s bash -c "
531
- set -euo pipefail
532
- cd '$(pwd)'
533
- export DRY_RUN=0
534
- export MOXIE_ROOT='$MOXIE_ROOT'
535
- export MOXIE_LIB='$MOXIE_LIB'
536
- source '$MOXIE_LIB/core.sh'
537
- source '$MOXIE_LIB/agents.sh'
538
- source '$MOXIE_LIB/phases.sh'
539
- source '$MOXIE_LIB/tokens.sh'
540
- load_agents
541
- _run_pipeline ${run_phases[*]}
542
- "
849
+ if [ "$MOXIE_PLATFORM" = "darwin" ]; then
850
+ caffeinate -d -i -s bash -c "
851
+ set -euo pipefail; cd '$(pwd)'
852
+ export DRY_RUN=0 MOXIE_ROOT='$MOXIE_ROOT' MOXIE_LIB='$MOXIE_LIB'
853
+ source '$MOXIE_LIB/platform.sh'; source '$MOXIE_LIB/core.sh'
854
+ source '$MOXIE_LIB/agents.sh'; source '$MOXIE_LIB/phases.sh'
855
+ source '$MOXIE_LIB/tokens.sh'
856
+ load_agents; _run_pipeline ${run_phases[*]}
857
+ "
858
+ elif [ "$MOXIE_PLATFORM" = "linux" ] && command -v systemd-inhibit &>/dev/null; then
859
+ systemd-inhibit --what=idle:sleep --who=moxie --why="moxie pipeline" bash -c "
860
+ set -euo pipefail; cd '$(pwd)'
861
+ export DRY_RUN=0 MOXIE_ROOT='$MOXIE_ROOT' MOXIE_LIB='$MOXIE_LIB'
862
+ source '$MOXIE_LIB/platform.sh'; source '$MOXIE_LIB/core.sh'
863
+ source '$MOXIE_LIB/agents.sh'; source '$MOXIE_LIB/phases.sh'
864
+ source '$MOXIE_LIB/tokens.sh'
865
+ load_agents; _run_pipeline ${run_phases[*]}
866
+ "
867
+ else
868
+ bash -c "
869
+ set -euo pipefail; cd '$(pwd)'
870
+ export DRY_RUN=0 MOXIE_ROOT='$MOXIE_ROOT' MOXIE_LIB='$MOXIE_LIB'
871
+ source '$MOXIE_LIB/platform.sh'; source '$MOXIE_LIB/core.sh'
872
+ source '$MOXIE_LIB/agents.sh'; source '$MOXIE_LIB/phases.sh'
873
+ source '$MOXIE_LIB/tokens.sh'
874
+ load_agents; _run_pipeline ${run_phases[*]}
875
+ "
876
+ fi
543
877
  fi
544
878
  }
545
879
 
@@ -547,6 +881,8 @@ cmd_run() {
547
881
 
548
882
  cmd_start() {
549
883
  require_moxie_project
884
+ load_agents
885
+ check_minimum_agents || exit 1
550
886
 
551
887
  local target_phase=""
552
888
  while [[ $# -gt 0 ]]; do
@@ -582,28 +918,30 @@ cmd_start() {
582
918
  echo " Stop: moxie stop"
583
919
  echo ""
584
920
 
585
- # Launch in background, caffeinated
586
- nohup caffeinate -d -i -s bash -c "
587
- set -euo pipefail
588
- cd '$(pwd)'
589
- export DRY_RUN=0
590
- export MOXIE_ROOT='$MOXIE_ROOT'
591
- export MOXIE_LIB='$MOXIE_LIB'
592
- source '$MOXIE_LIB/core.sh'
593
- source '$MOXIE_LIB/agents.sh'
594
- source '$MOXIE_LIB/phases.sh'
921
+ # Launch in background with platform-appropriate sleep inhibitor
922
+ local _bg_script="
923
+ set -euo pipefail; cd '$(pwd)'
924
+ export DRY_RUN=0 MOXIE_ROOT='$MOXIE_ROOT' MOXIE_LIB='$MOXIE_LIB'
925
+ source '$MOXIE_LIB/platform.sh'; source '$MOXIE_LIB/core.sh'
926
+ source '$MOXIE_LIB/agents.sh'; source '$MOXIE_LIB/phases.sh'
595
927
  source '$MOXIE_LIB/tokens.sh'
596
928
  load_agents
597
-
598
- # Determine phases
599
929
  if [ -n '$target_phase' ]; then
600
930
  phases=('$target_phase')
601
931
  else
602
932
  phases=(\${PHASES[@]})
603
933
  fi
604
-
605
934
  _run_pipeline \${phases[*]}
606
- " > "$log_file" 2>&1 &
935
+ "
936
+
937
+ if [ "$MOXIE_PLATFORM" = "darwin" ]; then
938
+ nohup caffeinate -d -i -s bash -c "$_bg_script" > "$log_file" 2>&1 &
939
+ elif [ "$MOXIE_PLATFORM" = "linux" ] && command -v systemd-inhibit &>/dev/null; then
940
+ nohup systemd-inhibit --what=idle:sleep --who=moxie --why="moxie pipeline" \
941
+ bash -c "$_bg_script" > "$log_file" 2>&1 &
942
+ else
943
+ nohup bash -c "$_bg_script" > "$log_file" 2>&1 &
944
+ fi
607
945
 
608
946
  local bg_pid=$!
609
947
  echo "$bg_pid" > "$MOXIE_DIR/moxie.pid"
@@ -667,7 +1005,11 @@ _print_run_banner() {
667
1005
  echo ""
668
1006
  echo "Started: $(date)"
669
1007
  if [ "${DRY_RUN:-0}" = "0" ]; then
670
- echo "Machine will stay awake (caffeinate)."
1008
+ local _inhibitor
1009
+ _inhibitor=$(sleep_inhibit_name)
1010
+ if [ "$_inhibitor" != "(none)" ]; then
1011
+ echo "Machine will stay awake ($_inhibitor)."
1012
+ fi
671
1013
  else
672
1014
  echo "Mode: DRY RUN"
673
1015
  fi
@@ -759,6 +1101,90 @@ print(f' {grand:,} tokens')
759
1101
  " 2>/dev/null || echo " (unknown)"
760
1102
  }
761
1103
 
1104
+ # ---- Convergence helpers ----
1105
+
1106
+ # Returns a stable fingerprint of the current findings in a ledger.
1107
+ # Extracts all INCOMPLETE/INACCURATE section labels, sorts them, and hashes.
1108
+ # If findings are identical across rotations, the hash won't change.
1109
+ _findings_fingerprint() {
1110
+ local ledger="$1"
1111
+ python3 -c "
1112
+ import json, hashlib
1113
+ with open('$ledger') as f:
1114
+ d = json.load(f)
1115
+ # Collect all finding descriptions from agent outputs
1116
+ findings = set()
1117
+ for name, info in d.get('agents', {}).items():
1118
+ out = info.get('output_file', '')
1119
+ note = info.get('notes', '')
1120
+ # Use notes field as findings summary (agents write their review status here)
1121
+ if note:
1122
+ findings.add(note[:200])
1123
+ # Also check the top-level findings array if present
1124
+ for f in d.get('findings', []):
1125
+ if isinstance(f, str):
1126
+ findings.add(f[:200])
1127
+ elif isinstance(f, dict):
1128
+ findings.add(str(f.get('section', '')) + ':' + str(f.get('status', '')))
1129
+ # Stable fingerprint: sorted findings hashed
1130
+ key = '|'.join(sorted(findings))
1131
+ print(hashlib.md5(key.encode()).hexdigest() if key else '')
1132
+ " 2>/dev/null
1133
+ }
1134
+
1135
+ # Force-accept the best available draft as FINAL for a phase.
1136
+ # Picks the most recent .md file (by filename timestamp) from any agent.
1137
+ _force_accept_best_draft() {
1138
+ local phase_dir="$1"
1139
+ local final_prefix="$2"
1140
+ local ledger="$3"
1141
+
1142
+ # Find the most recent agent draft (not a FINAL file)
1143
+ local best=""
1144
+ for f in "$phase_dir"/*.md; do
1145
+ [ ! -f "$f" ] && continue
1146
+ case "$(basename "$f")" in
1147
+ ${final_prefix}-*) continue ;; # skip existing FINAL files
1148
+ esac
1149
+ # Pick the one with the latest filename (timestamps sort lexically)
1150
+ if [ -z "$best" ] || [[ "$(basename "$f")" > "$(basename "$best")" ]]; then
1151
+ best="$f"
1152
+ fi
1153
+ done
1154
+
1155
+ if [ -z "$best" ]; then
1156
+ echo " No draft files found to accept." >&2
1157
+ return 1
1158
+ fi
1159
+
1160
+ local ts
1161
+ ts=$(date +"%m%d%y-%H%M%S")
1162
+ local final_file="$phase_dir/${final_prefix}-${ts}.md"
1163
+
1164
+ cp "$best" "$final_file"
1165
+
1166
+ # Also copy to spec.md if this is the RFC phase
1167
+ if [ "$final_prefix" = "RFC-FINAL" ]; then
1168
+ cp "$final_file" "$MOXIE_DIR/spec.md"
1169
+ fi
1170
+
1171
+ # Mark all healthy agents as reached in the ledger
1172
+ python3 -c "
1173
+ import json
1174
+ with open('$ledger') as f:
1175
+ d = json.load(f)
1176
+ for name in d.get('agents', {}):
1177
+ d['agents'][name]['reached'] = True
1178
+ d['status'] = '$(echo "$final_prefix" | tr '[:upper:]' '[:lower:]' | tr '-' '_')'
1179
+ with open('$ledger', 'w') as f:
1180
+ json.dump(d, f, indent=2)
1181
+ " 2>/dev/null
1182
+
1183
+ echo " Accepted: $(basename "$best") → $(basename "$final_file")"
1184
+ echo ""
1185
+ _show_quorum "$ledger"
1186
+ }
1187
+
762
1188
  # ---- Phase execution ----
763
1189
 
764
1190
  _run_phase() {
@@ -778,11 +1204,19 @@ _run_phase() {
778
1204
 
779
1205
  # Randomize rotation order for this phase
780
1206
  _shuffle_agents
1207
+ _init_agent_health
781
1208
  echo "Rotation order for $(phase_label "$phase"): ${AGENT_NAMES[*]}"
782
1209
  echo ""
783
1210
 
784
1211
  banner "moxie: $(phase_label "$phase") phase" "$max_rounds" "$turn_timeout" "${DRY_RUN:-0}"
785
1212
 
1213
+ # Convergence detection: track findings fingerprints across rounds.
1214
+ # If the same findings repeat for STALE_THRESHOLD consecutive full rotations
1215
+ # (each agent gets a turn), force-accept the best draft.
1216
+ local STALE_THRESHOLD=3
1217
+ local stale_count=0
1218
+ local prev_fingerprint=""
1219
+
786
1220
  # Show initial ledger
787
1221
  echo "Quorum status:"
788
1222
  _show_quorum "$ledger"
@@ -791,27 +1225,43 @@ _run_phase() {
791
1225
  local current="${AGENT_NAMES[0]}"
792
1226
 
793
1227
  for round in $(seq 1 "$max_rounds"); do
1228
+ # Skip degraded agents
1229
+ local skipped=0
1230
+ while is_agent_degraded "$current"; do
1231
+ current=$(next_agent "$current")
1232
+ skipped=$((skipped + 1))
1233
+ # Safety: don't infinite-loop if all agents are degraded
1234
+ if [ "$skipped" -ge "$AGENT_COUNT" ]; then
1235
+ echo ""
1236
+ echo "FATAL: All agents are degraded. Pipeline cannot continue."
1237
+ _show_quorum "$ledger"
1238
+ return 1
1239
+ fi
1240
+ done
1241
+
794
1242
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
795
1243
  echo " Turn $round/$max_rounds — $current"
796
1244
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
797
1245
 
798
- # Check if FINAL already exists (another agent wrote it) AND all agents reached
1246
+ # Check if FINAL already exists AND healthy agents have all reached quorum
799
1247
  if ls "$phase_dir"/${final_prefix}-*.md &>/dev/null; then
800
- if _all_agents_reached "$ledger"; then
1248
+ if _healthy_agents_reached "$ledger"; then
801
1249
  local final
802
1250
  final=$(ls -1 "$phase_dir"/${final_prefix}-*.md | tail -1)
1251
+ local healthy
1252
+ healthy=$(count_healthy_agents)
803
1253
  echo ""
804
1254
  echo "╔════════════════════════════════════════════════════════════╗"
805
- printf "║ %-58s║\n" "$(phase_label "$phase") phase complete — unanimous quorum"
1255
+ printf "║ %-58s║\n" "$(phase_label "$phase") phase complete — quorum reached"
806
1256
  printf "║ Final: %-52s║\n" "$(basename "$final")"
807
- printf "║ All %d agents signed off ║\n" "$AGENT_COUNT"
1257
+ printf "║ %d/%d agents signed off ║\n" "$healthy" "$AGENT_COUNT"
808
1258
  echo "╚════════════════════════════════════════════════════════════╝"
809
1259
  echo ""
810
1260
  show_phase_tokens "$csv"
811
1261
  return 0
812
1262
  else
813
- # FINAL file exists but not all agents reached — keep going
814
- echo " FINAL file exists but not all agents have signed off yet."
1263
+ # FINAL file exists but not all healthy agents reached — keep going
1264
+ echo " FINAL file exists but not all healthy agents have signed off yet."
815
1265
  echo ""
816
1266
  fi
817
1267
  fi
@@ -825,39 +1275,83 @@ _run_phase() {
825
1275
 
826
1276
  # Run
827
1277
  dispatch_logged "$current" "$prompt_file" "$turn_timeout" "$log_dir" "$round" "$csv" "$phase"
1278
+ local dispatch_rc=$?
828
1279
  rm -f "$prompt_file"
829
1280
 
1281
+ # If _record_turn_health returned 2 (fatal: < 2 agents remaining), stop
1282
+ if [ "$dispatch_rc" = "2" ] || [ "$(count_healthy_agents)" -lt 2 ]; then
1283
+ echo ""
1284
+ echo "FATAL: Fewer than 2 healthy agents remaining. Pipeline cannot continue."
1285
+ _show_quorum "$ledger"
1286
+ return 1
1287
+ fi
1288
+
830
1289
  echo ""
831
1290
  echo "Quorum after $current's turn:"
832
1291
  _show_quorum "$ledger"
833
1292
 
834
- # Check unanimous quorum after this turn
835
- if _all_agents_reached "$ledger"; then
1293
+ # Check quorum among healthy agents after this turn
1294
+ if _healthy_agents_reached "$ledger"; then
836
1295
  # Verify FINAL file was actually written by the agent
837
1296
  if ls "$phase_dir"/${final_prefix}-*.md &>/dev/null; then
838
1297
  local final
839
1298
  final=$(ls -1 "$phase_dir"/${final_prefix}-*.md | tail -1)
1299
+ local healthy
1300
+ healthy=$(count_healthy_agents)
840
1301
  echo ""
841
1302
  echo "╔════════════════════════════════════════════════════════════╗"
842
- printf "║ %-58s║\n" "$(phase_label "$phase") phase complete — unanimous quorum"
1303
+ printf "║ %-58s║\n" "$(phase_label "$phase") phase complete — quorum reached"
843
1304
  printf "║ Final: %-52s║\n" "$(basename "$final")"
844
- printf "║ All %d agents signed off ║\n" "$AGENT_COUNT"
1305
+ printf "║ %d/%d agents signed off ║\n" "$healthy" "$AGENT_COUNT"
845
1306
  echo "╚════════════════════════════════════════════════════════════╝"
846
1307
  echo ""
847
1308
  show_phase_tokens "$csv"
848
1309
  return 0
849
1310
  else
850
- echo " All agents reached quorum but FINAL file not yet written."
1311
+ echo " All healthy agents reached quorum but FINAL file not yet written."
851
1312
  echo " Next agent will write it."
852
1313
  fi
853
1314
  fi
854
1315
 
1316
+ # ---- Convergence detection ----
1317
+ # After each full rotation (every AGENT_COUNT turns), fingerprint the
1318
+ # findings. If findings stabilize for STALE_THRESHOLD rotations, the
1319
+ # agents are in a perfectionist loop — force-accept the best draft.
1320
+ if [ $(( round % $(count_healthy_agents) )) -eq 0 ]; then
1321
+ local fingerprint
1322
+ fingerprint=$(_findings_fingerprint "$ledger")
1323
+
1324
+ if [ "$fingerprint" = "$prev_fingerprint" ] && [ -n "$fingerprint" ]; then
1325
+ stale_count=$((stale_count + 1))
1326
+ echo " [convergence] Findings unchanged for $stale_count rotation(s) ($STALE_THRESHOLD triggers force-accept)"
1327
+ else
1328
+ stale_count=0
1329
+ prev_fingerprint="$fingerprint"
1330
+ fi
1331
+
1332
+ if [ "$stale_count" -ge "$STALE_THRESHOLD" ]; then
1333
+ echo ""
1334
+ echo "╔════════════════════════════════════════════════════════════╗"
1335
+ printf "║ %-58s║\n" "$(phase_label "$phase") — force-accepting (convergence stall)"
1336
+ echo "║ Findings stabilized for $STALE_THRESHOLD rotations. ║"
1337
+ echo "║ Accepting best available draft as FINAL. ║"
1338
+ echo "╚════════════════════════════════════════════════════════════╝"
1339
+ echo ""
1340
+ _force_accept_best_draft "$phase_dir" "$final_prefix" "$ledger"
1341
+ show_phase_tokens "$csv"
1342
+ return 0
1343
+ fi
1344
+ fi
1345
+
855
1346
  echo ""
856
1347
  current=$(next_agent "$current")
857
1348
  done
858
1349
 
1350
+ # If max rounds exhausted, force-accept rather than failing
859
1351
  echo ""
860
- echo "WARNING: $(phase_label "$phase") phase reached max rounds ($max_rounds) without unanimous quorum."
1352
+ echo "WARNING: $(phase_label "$phase") phase reached max rounds ($max_rounds) without quorum."
1353
+ echo "Force-accepting best available draft."
1354
+ _force_accept_best_draft "$phase_dir" "$final_prefix" "$ledger"
861
1355
  _show_quorum "$ledger"
862
1356
  echo ""
863
1357
  show_phase_tokens "$csv"