@matt82198/aesop 0.3.2 → 0.4.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 (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +24 -9
  3. package/aesop.config.example.json +16 -0
  4. package/bin/cli.js +107 -34
  5. package/daemons/run-watchdog.sh +6 -2
  6. package/docs/CONFIGURE.md +31 -20
  7. package/docs/FIRST-WAVE.md +29 -9
  8. package/docs/INSTALL.md +181 -26
  9. package/docs/MICROKERNEL.md +452 -0
  10. package/docs/PORTING.md +4 -0
  11. package/docs/README.md +7 -3
  12. package/docs/THE-AESOP-HYPOTHESIS.md +156 -0
  13. package/driver/CLAUDE.md +123 -123
  14. package/driver/README.md +40 -2
  15. package/driver/adjudication_gate.py +367 -0
  16. package/driver/aesop.config.example.json +20 -4
  17. package/driver/backend_config.py +603 -12
  18. package/driver/claude_code_driver.py +9 -17
  19. package/driver/codex_driver.py +80 -25
  20. package/driver/context_pack.py +454 -0
  21. package/driver/openai_compatible_driver.py +53 -11
  22. package/driver/openai_transport.py +31 -10
  23. package/driver/orchestrator_backend.py +332 -0
  24. package/driver/orchestrator_driver.py +589 -0
  25. package/driver/proc_util.py +134 -0
  26. package/driver/wave_loop.py +801 -59
  27. package/driver/wave_scheduler.py +361 -37
  28. package/monitor/collect-signals.mjs +83 -0
  29. package/package.json +1 -1
  30. package/state_store/coordination.py +65 -5
  31. package/tools/ci_merge_wait.py +88 -42
  32. package/tools/ci_shard_runner.py +128 -0
  33. package/tools/doctor.js +124 -12
  34. package/tools/reproduce.js +11 -2
  35. package/tools/seated_shadow_adjudication.py +920 -0
  36. package/tools/self_stats.py +61 -6
  37. package/tools/shadow_adjudication.py +1024 -0
  38. package/tools/verify_test_suite_count.py +250 -0
  39. package/tools/verify_ui_trio_redaction_proof.py +292 -0
  40. package/tools/wave_manifest_lint.py +485 -0
  41. package/tools/wave_scorecard.py +430 -0
  42. package/ui/config.py +1 -1
  43. package/ui/cost.py +176 -3
  44. package/ui/web/dist/assets/{index-CP68RIh3.js → index-4rp6B_ID.js} +1 -1
  45. package/ui/web/dist/assets/{index-CNQxaiOW.css → index-B2lTtpsH.css} +1 -1
  46. package/ui/web/dist/index.html +2 -2
@@ -10,6 +10,22 @@ WS3a pilot: deterministic one-cycle loop with CRITICAL GUARDRAILS:
10
10
  6. Double-dispatch prevention: write "in_progress" status to tracker.json (atomic, conflict-detecting)
11
11
  7. Bounded by: HALT file check (final gate before dispatch) + cost ceiling check
12
12
 
13
+ HS-2 BLOCK GATE (live orchestrator seat only; default Report shape unchanged):
14
+ - Items the seat BLOCKS get the TERMINAL tracker status "blocked" in the same
15
+ atomic write as shipped items' "in_progress" -> never re-selected/rebuilt
16
+ (the tracker state is the durable block record, not the journal entry).
17
+ - Report gains "blocked": [{slug, reason, quarantine}] (empty list when the
18
+ seat ran and blocked nothing). quarantine is "clean" | "errors" (+
19
+ quarantine_errors: the per-file error records -- refused code may still
20
+ be in the tree) | "skipped" (+ quarantine_skipped_reason) | "unknown"
21
+ (no build record). Also "orchestrator_gate": {seat, model, decisions,
22
+ verdict_counts{merge,block,escalate,undetermined,decision_failed},
23
+ blocked[], decision_failed[], seat_tokens_spent, status}. status is
24
+ "degraded" when the gate made zero successful decisions (all
25
+ DECISION_FAILED -> items still ship crash-only, but the outage is LOUD),
26
+ "no_decisions" when nothing was verified to review, else "active".
27
+ - success is False whenever any item was blocked (never silently True).
28
+
13
29
  SINGLE-WRITER ASSUMPTION: This pilot assumes tracker.json is NOT edited concurrently by other
14
30
  processes. Concurrent-writer safety checks detect conflicts and abort; full lock/StateAPI
15
31
  integration is filed for next wave. Do NOT run multiple schedulers against the same tracker.
@@ -363,6 +379,7 @@ def build_wave_manifest(
363
379
  def _write_tracker_status_atomic(
364
380
  tracker_path: str, items_to_update: List[str], new_status: str, wave_id: str,
365
381
  expected_hash: Optional[str] = None,
382
+ status_by_id: Optional[Dict[str, str]] = None,
366
383
  ) -> Tuple[bool, Optional[str]]:
367
384
  """Write item status updates to tracker.json atomically (P1-5, P6, HIGH).
368
385
 
@@ -375,6 +392,10 @@ def _write_tracker_status_atomic(
375
392
  new_status: new status (should be "in_progress" for pilot)
376
393
  wave_id: wave ID to record in notes
377
394
  expected_hash: content hash from intake; if current content differs, abort with conflict
395
+ status_by_id: optional ADDITIONAL per-id statuses written in the same
396
+ atomic replace (HS-2 block gate: blocked items get the TERMINAL
397
+ "blocked" status alongside shipped items' "in_progress"). Entries
398
+ here override items_to_update on collision (terminal state wins).
378
399
 
379
400
  Returns:
380
401
  (success: bool, error_reason: str|None)
@@ -408,11 +429,14 @@ def _write_tracker_status_atomic(
408
429
  else:
409
430
  return False, "invalid_tracker_format"
410
431
 
411
- # Update items
412
- items_to_update_set = set(items_to_update)
432
+ # Update items: base list gets new_status; status_by_id entries
433
+ # override (terminal states like "blocked" win over "in_progress").
434
+ status_map = {item_id: new_status for item_id in items_to_update}
435
+ if status_by_id:
436
+ status_map.update(status_by_id)
413
437
  for item in items:
414
- if item.get("id") in items_to_update_set:
415
- item["status"] = new_status
438
+ if item.get("id") in status_map:
439
+ item["status"] = status_map[item.get("id")]
416
440
  notes = item.get("notes", "")
417
441
  item["notes"] = f"{notes} [wave {wave_id[:8]}]".strip()
418
442
 
@@ -471,11 +495,28 @@ def emit_report(
471
495
  tracker_unmapped_slugs: Optional[List[str]] = None,
472
496
  success: bool = False,
473
497
  merged: bool = False,
498
+ blocked: Optional[List[Dict[str, Any]]] = None,
499
+ orchestrator_gate: Optional[Dict[str, Any]] = None,
474
500
  ) -> Dict[str, Any]:
475
501
  """Emit a Report JSON structure (GATE-1 HANDOFF KIT).
476
502
 
477
503
  Per-item observability: items_shipped includes full details {slug, backend, tier, verified, testExit}.
478
504
 
505
+ HS-2 block-gate fields (present ONLY when a live orchestrator seat ran;
506
+ the default no-seat Report shape is unchanged):
507
+ blocked: list of {slug, reason, quarantine} for items the seat refused
508
+ to ship (empty list when the seat ran and blocked nothing).
509
+ quarantine: "clean" | "errors" (+ quarantine_errors per-file
510
+ records: refused code may still be in the tree) | "skipped"
511
+ (+ quarantine_skipped_reason) | "unknown" (no build record).
512
+ orchestrator_gate: seat activity summary {seat, model, decisions,
513
+ verdict_counts {merge, block, escalate, undetermined,
514
+ decision_failed}, blocked [slugs], decision_failed [slugs],
515
+ seat_tokens_spent, status "active"|"degraded"|"no_decisions"}.
516
+ status "degraded" = the gate made ZERO successful decisions
517
+ (every one DECISION_FAILED): items still ship (crash-only),
518
+ but the outage is loud instead of invisible.
519
+
479
520
  Returns:
480
521
  report dict (ready to serialize)
481
522
  """
@@ -509,6 +550,13 @@ def emit_report(
509
550
  report["tracker_update_attempted"] = True
510
551
  if tracker_unmapped_slugs:
511
552
  report["tracker_unmapped_slugs"] = tracker_unmapped_slugs
553
+ # HS-2 block gate: include the seat-observability lanes whenever a live
554
+ # seat ran (blocked may be an EMPTY list -- deterministic live-seat
555
+ # shape); both stay absent on the default no-seat path (no-op invariant).
556
+ if blocked is not None:
557
+ report["blocked"] = blocked
558
+ if orchestrator_gate is not None:
559
+ report["orchestrator_gate"] = orchestrator_gate
512
560
 
513
561
  return report
514
562
 
@@ -523,6 +571,7 @@ def run_wave_scheduler(
523
571
  dry_run: bool = False,
524
572
  driver: Optional[AgentDriver] = None,
525
573
  state_dir: Optional[Path] = None,
574
+ orchestrator_backend: Optional[Any] = None,
526
575
  ) -> Dict[str, Any]:
527
576
  """Run one complete wave cycle (intake -> manifest -> dispatch -> report).
528
577
 
@@ -532,6 +581,13 @@ def run_wave_scheduler(
532
581
  dry_run: if True, print manifest without dispatch
533
582
  driver: AgentDriver instance (defaults to FakeDriver for testing)
534
583
  state_dir: state directory (defaults to ./state)
584
+ orchestrator_backend: optional OrchestratorBackend for the decision
585
+ seat (HS-2). None (default) keeps the live harness as the
586
+ orchestrator -- byte-identical to pre-HS-2, no key required.
587
+ A live backend (see resolve_orchestrator_backend) routes the
588
+ final_catch decision per verified item through run_wave's
589
+ Phase 6; verdict 'block' stops that item from shipping. The
590
+ Report JSON shape is IDENTICAL either way (swap transparency).
535
591
 
536
592
  Returns:
537
593
  Report dict (phase, wave_id, items_selected, items_shipped, etc.)
@@ -697,6 +753,7 @@ def run_wave_scheduler(
697
753
  state_dir=state_dir,
698
754
  git={"expectTopLevel": str(REPO)},
699
755
  resume_journal=True,
756
+ orchestrator_backend=orchestrator_backend,
700
757
  )
701
758
 
702
759
  # P2c: verify no merged=True, record merged=false
@@ -725,14 +782,56 @@ def run_wave_scheduler(
725
782
  "buildRecord": bool(b),
726
783
  })
727
784
 
785
+ # HS-2 BLOCK GATE: extract the orchestrator seat's review (present
786
+ # ONLY when a live seat ran). Blocked items need a TERMINAL tracker
787
+ # state ("blocked", never left todo -> no rebuild loop) and a durable,
788
+ # observable lane in the Report.
789
+ review = wave_result.get("orchestrator_review")
790
+ blocked_slugs = []
791
+ blocked_lane = []
792
+ if isinstance(review, dict):
793
+ blocked_slugs = [s for s in (review.get("blocked") or [])]
794
+ reason_by_slug = {}
795
+ for d in review.get("blocked_detail") or []:
796
+ if isinstance(d, dict) and d.get("slug"):
797
+ reason_by_slug[d["slug"]] = d.get("reason")
798
+ built_by_slug = {
799
+ b_.get("slug"): b_
800
+ for b_ in (wave_result.get("built") or [])
801
+ if isinstance(b_, dict) and b_.get("slug")
802
+ }
803
+ for s in blocked_slugs:
804
+ entry = {
805
+ "slug": s,
806
+ "reason": reason_by_slug.get(s)
807
+ or "orchestrator final_catch verdict: block",
808
+ }
809
+ # QUARANTINE VISIBILITY: a failed quarantine (refused code
810
+ # still in the tree) must never look like a clean block.
811
+ q = (built_by_slug.get(s) or {}).get("quarantine")
812
+ if isinstance(q, dict):
813
+ if q.get("errors"):
814
+ entry["quarantine"] = "errors"
815
+ entry["quarantine_errors"] = q["errors"]
816
+ elif q.get("skipped_reason"):
817
+ entry["quarantine"] = "skipped"
818
+ entry["quarantine_skipped_reason"] = q["skipped_reason"]
819
+ else:
820
+ entry["quarantine"] = "clean"
821
+ else:
822
+ entry["quarantine"] = "unknown"
823
+ blocked_lane.append(entry)
824
+
728
825
  # TRACKER UPDATE FIRST (live-pilot lesson: a crash in Report assembly
729
826
  # must never leave shipped-but-unmarked items). Attempted as soon as
730
827
  # shipped_slugs is known; outcome fields survive into ANY report,
731
- # including the exception envelope below.
828
+ # including the exception envelope below. Blocked items are marked in
829
+ # the SAME atomic write: the tracker terminal state is the durable
830
+ # block record (the journal entry can be overwritten on resume).
732
831
  tracker_update_attempted = False
733
832
  tracker_update_error = None
734
833
  tracker_unmapped = []
735
- if shipped_slugs:
834
+ if shipped_slugs or blocked_slugs:
736
835
  tracker_update_attempted = True
737
836
  try:
738
837
  slug_to_id = {it.get("slug"): it.get("id") for it in selected_items}
@@ -745,18 +844,28 @@ def run_wave_scheduler(
745
844
  # LOUD, never silent: an unmapped shipped slug means the
746
845
  # tracker cannot be marked -> double-dispatch risk.
747
846
  tracker_unmapped.append(s_)
748
- if shipped_item_ids:
847
+ blocked_status_by_id = {}
848
+ for s_ in blocked_slugs:
849
+ id_ = slug_to_id.get(s_)
850
+ if id_:
851
+ blocked_status_by_id[id_] = "blocked"
852
+ else:
853
+ # Unmapped blocked slug = tracker stays todo = the
854
+ # rebuild-and-block loop this fix exists to stop.
855
+ tracker_unmapped.append(s_)
856
+ if shipped_item_ids or blocked_status_by_id:
749
857
  success_update, update_error = _write_tracker_status_atomic(
750
858
  tracker_path,
751
859
  shipped_item_ids,
752
860
  "in_progress",
753
861
  wave_id,
754
862
  expected_hash=intake_hash, # P6: conflict detection
863
+ status_by_id=blocked_status_by_id,
755
864
  )
756
865
  if not success_update:
757
866
  tracker_update_error = update_error
758
867
  if tracker_unmapped and tracker_update_error is None:
759
- tracker_update_error = "unmapped_shipped_slugs"
868
+ tracker_update_error = "unmapped_slugs"
760
869
  except Exception as te:
761
870
  tracker_update_error = f"tracker_update_exception: {te}"
762
871
 
@@ -765,9 +874,36 @@ def run_wave_scheduler(
765
874
  sha = next((r.get("sha") for r in repo_results if isinstance(r, dict) and r.get("sha")), None)
766
875
  branch = None # run_wave ships on the current branch; scheduler does not switch branches
767
876
 
877
+ # Orchestrator-gate summary for the Report (live seat only): the
878
+ # operator must be able to SEE the gate's real activity -- including
879
+ # a silently-neutered seat (every decision DECISION_FAILED).
880
+ orchestrator_gate = None
881
+ if isinstance(review, dict):
882
+ decisions = int(review.get("decisions") or 0)
883
+ failed_slugs = list(review.get("decision_failed") or [])
884
+ gate_status = review.get("gate_status")
885
+ if not gate_status:
886
+ if decisions == 0:
887
+ gate_status = "no_decisions"
888
+ elif len(failed_slugs) == decisions:
889
+ gate_status = "degraded"
890
+ else:
891
+ gate_status = "active"
892
+ orchestrator_gate = {
893
+ "seat": review.get("seat"),
894
+ "model": review.get("model"),
895
+ "decisions": decisions,
896
+ "verdict_counts": review.get("verdict_counts") or {},
897
+ "blocked": blocked_slugs,
898
+ "decision_failed": failed_slugs,
899
+ "seat_tokens_spent": review.get("seat_tokens_spent", 0),
900
+ "status": gate_status,
901
+ }
902
+
768
903
  # run_wave has NO top-level "success" key: derive honestly. success
769
904
  # additionally requires EVERY shipped item verified (contract: a
770
- # shipped-but-unproven item is not a successful wave).
905
+ # shipped-but-unproven item is not a successful wave) and NO blocked
906
+ # items (a block is a refused ship, never a silent True).
771
907
  wave_ok = bool(wave_result.get("preflight_ok")) and not wave_result.get("aborted")
772
908
  all_verified = all(i.get("verified") for i in items_shipped) if items_shipped else True
773
909
  return emit_report(
@@ -781,8 +917,15 @@ def run_wave_scheduler(
781
917
  tracker_update_attempted=tracker_update_attempted,
782
918
  tracker_update_error=tracker_update_error,
783
919
  tracker_unmapped_slugs=tracker_unmapped if tracker_unmapped else None,
784
- success=wave_ok and all_verified and tracker_update_error is None,
920
+ success=(
921
+ wave_ok
922
+ and all_verified
923
+ and tracker_update_error is None
924
+ and not blocked_slugs
925
+ ),
785
926
  merged=False, # P2c: pilot stops before merge
927
+ blocked=blocked_lane if isinstance(review, dict) else None,
928
+ orchestrator_gate=orchestrator_gate,
786
929
  )
787
930
 
788
931
  except Exception as e:
@@ -804,8 +947,175 @@ def run_wave_scheduler(
804
947
  # CLI
805
948
  # ========================================================================
806
949
 
950
+ def resolve_worker_driver(
951
+ driver_override: Optional[str] = None,
952
+ config_path: Optional[str] = None,
953
+ execute: bool = False,
954
+ ) -> Tuple[Optional[AgentDriver], Optional[str]]:
955
+ """Resolve the worker-seat driver (HS-1): config-first, --driver overrides.
956
+
957
+ Resolution order:
958
+ 1. driver_override 'claude'/'codex' -> that driver, exactly as the
959
+ legacy hardcoded CLI path behaved (including the codex+execute
960
+ OPENAI_API_KEY gate).
961
+ 2. Otherwise: load aesop.config.json and activate a configured worker
962
+ ONLY when a seats.worker block is present (build_driver on the
963
+ validated seat). The seats block is the opt-in surface; it can
964
+ select ANY backend, including openai-compatible (previously
965
+ unreachable from this CLI).
966
+ 3. No config file, no seats.worker -> ClaudeCodeDriver, byte-identical
967
+ to pre-0.4.0. A bare LEGACY FLAT backend block ({"backend": ...}
968
+ with no seats) stays INERT here: it was dead config before 0.4.0
969
+ (documented but consumed by nothing), and silently activating it
970
+ would change behavior on existing installs. Migrate it to
971
+ seats.worker to opt in. It still fails loud if malformed.
972
+
973
+ For hosted seats with execute=True, the seat's api_key_env (default
974
+ OPENAI_API_KEY) must be set; is_local seats need no key. Dry runs never
975
+ require a key (building a driver is offline-safe).
976
+
977
+ Returns:
978
+ (driver, None) on success; (None, error_message) on failure.
979
+ """
980
+ key_env_default = "OPENAI" + "_" + "API" + "_" + "KEY"
981
+
982
+ if driver_override == "codex":
983
+ # CodexDriver requires OPENAI_API_KEY for execute; dry-run works without it
984
+ try:
985
+ from codex_driver import CodexDriver
986
+ except ImportError:
987
+ return None, "--driver codex requires codex_driver.py"
988
+ if execute and not os.environ.get(key_env_default):
989
+ return None, (
990
+ f"--driver codex --execute requires {key_env_default} "
991
+ f"environment variable"
992
+ )
993
+ return CodexDriver(), None
994
+
995
+ if driver_override == "claude":
996
+ try:
997
+ from claude_code_driver import ClaudeCodeDriver
998
+ except ImportError:
999
+ return None, "claude_code_driver.py not found"
1000
+ return ClaudeCodeDriver(), None
1001
+
1002
+ if driver_override is not None:
1003
+ return None, f"unknown --driver '{driver_override}' (claude|codex)"
1004
+
1005
+ # Default: read the config (seats.worker or legacy backend block).
1006
+ try:
1007
+ from backend_config import build_driver, load_backend_config
1008
+ except ImportError:
1009
+ return None, "backend_config.py not found"
1010
+
1011
+ try:
1012
+ config = load_backend_config(config_path)
1013
+ except (TypeError, ValueError) as exc:
1014
+ return None, f"invalid aesop.config.json: {exc}"
1015
+
1016
+ seats = config.get("seats")
1017
+ has_worker_seat = isinstance(seats, dict) and isinstance(
1018
+ seats.get("worker"), dict
1019
+ )
1020
+ if not has_worker_seat:
1021
+ # No seats.worker opt-in: byte-identical to pre-0.4.0 (Claude
1022
+ # worker), even when a legacy flat backend block is present -- that
1023
+ # block was dead config before 0.4.0 and stays inert here.
1024
+ try:
1025
+ from claude_code_driver import ClaudeCodeDriver
1026
+ except ImportError:
1027
+ return None, "claude_code_driver.py not found"
1028
+ return ClaudeCodeDriver(), None
1029
+
1030
+ backend_name = config.get("backend", "claude")
1031
+ if execute and backend_name in ("codex", "openai-compatible"):
1032
+ if backend_name == "codex":
1033
+ key_env = key_env_default
1034
+ is_local = False
1035
+ else:
1036
+ key_env = config.get("api_key_env") or key_env_default
1037
+ is_local = bool(config.get("is_local", False))
1038
+ if not is_local and not os.environ.get(key_env):
1039
+ return None, (
1040
+ f"configured backend '{backend_name}' with --execute requires "
1041
+ f"{key_env} environment variable"
1042
+ )
1043
+
1044
+ try:
1045
+ return build_driver(config), None
1046
+ except (TypeError, ValueError, RuntimeError) as exc:
1047
+ return None, f"failed to build driver from config: {exc}"
1048
+
1049
+
1050
+ def resolve_orchestrator_backend(
1051
+ config_path: Optional[str] = None,
1052
+ execute: bool = False,
1053
+ ) -> Tuple[Optional[Any], Optional[str]]:
1054
+ """Resolve the orchestrator-seat backend (HS-2): config-first, opt-in.
1055
+
1056
+ Resolution:
1057
+ - No config file, no seats block, no seats.orchestrator, or backend
1058
+ 'harness'/'claude' -> (None, None): the live harness stays the
1059
+ orchestrator. run_wave's Phase 6 is byte-identical to pre-HS-2 --
1060
+ no OpenAI backend constructed, no key required.
1061
+ - seats.orchestrator backend 'openai-compatible' -> a live
1062
+ OpenAICompatibleOrchestratorBackend built via
1063
+ build_orchestrator_backend(load_backend_config()). Construction is
1064
+ offline-safe (no key read until decide_call time); with execute=True
1065
+ a hosted (non-is_local) seat requires its api_key_env to be set,
1066
+ mirroring the worker-seat gate. Dry runs never require a key.
1067
+ - Malformed config -> (None, error_message): fail loud, never a
1068
+ silent fallback to the harness.
1069
+
1070
+ Returns:
1071
+ (backend_or_None, error_message_or_None).
1072
+ """
1073
+ key_env_default = "OPENAI" + "_" + "API" + "_" + "KEY"
1074
+
1075
+ try:
1076
+ from backend_config import build_orchestrator_backend, load_backend_config
1077
+ except ImportError:
1078
+ return None, "backend_config.py not found"
1079
+
1080
+ try:
1081
+ config = load_backend_config(config_path)
1082
+ except (TypeError, ValueError) as exc:
1083
+ return None, f"invalid aesop.config.json: {exc}"
1084
+
1085
+ seats = config.get("seats")
1086
+ orch_seat = seats.get("orchestrator") if isinstance(seats, dict) else None
1087
+ if not isinstance(orch_seat, dict) or not orch_seat:
1088
+ return None, None
1089
+ if orch_seat.get("backend", "harness") in ("harness", "claude"):
1090
+ return None, None
1091
+
1092
+ try:
1093
+ backend = build_orchestrator_backend(config)
1094
+ except (TypeError, ValueError, RuntimeError) as exc:
1095
+ return None, f"failed to build orchestrator backend from config: {exc}"
1096
+
1097
+ # Defensive: the builder returns the null harness backend for any
1098
+ # residual default path -> that is the no-op seat, pass None through.
1099
+ try:
1100
+ from orchestrator_backend import HarnessOrchestratorBackend
1101
+ if isinstance(backend, HarnessOrchestratorBackend):
1102
+ return None, None
1103
+ except ImportError:
1104
+ pass
1105
+
1106
+ if execute and not bool(orch_seat.get("is_local", False)):
1107
+ key_env = orch_seat.get("api_key_env") or key_env_default
1108
+ if not os.environ.get(key_env):
1109
+ return None, (
1110
+ f"configured seats.orchestrator with --execute requires "
1111
+ f"{key_env} environment variable"
1112
+ )
1113
+
1114
+ return backend, None
1115
+
1116
+
807
1117
  def main():
808
- """CLI entry point (GATE-1: driver injection, --driver claude|codex)."""
1118
+ """CLI entry point (HS-1: config-driven worker seat; --driver overrides)."""
809
1119
  parser = argparse.ArgumentParser(
810
1120
  description="Wave scheduler: intake -> manifest -> dispatch -> report"
811
1121
  )
@@ -837,38 +1147,51 @@ def main():
837
1147
  parser.add_argument(
838
1148
  "--driver",
839
1149
  choices=["claude", "codex"],
840
- default="claude",
841
- help="Backend driver (claude|codex, default: claude)",
1150
+ default=None,
1151
+ help=(
1152
+ "OVERRIDE the configured worker seat (claude|codex). "
1153
+ "Default: read aesop.config.json seats.worker (no seats block "
1154
+ "-> claude; a legacy flat backend block stays inert -- migrate "
1155
+ "it to seats.worker); the config path also supports "
1156
+ "openai-compatible backends"
1157
+ ),
1158
+ )
1159
+ parser.add_argument(
1160
+ "--config",
1161
+ default=None,
1162
+ help="Path to aesop.config.json (default: ./aesop.config.json)",
842
1163
  )
843
1164
 
844
1165
  args = parser.parse_args()
845
1166
 
846
1167
  dry_run = not args.execute
847
1168
 
848
- # GATE-1: driver injection (instantiate based on --driver flag)
849
- driver = None
850
- if args.driver == "codex":
851
- # CodexDriver requires OPENAI_API_KEY for execute; --dry-run works without it
852
- try:
853
- from codex_driver import CodexDriver
854
- if args.execute and not os.environ.get("OPENAI_API_KEY"):
855
- print(
856
- "ERROR: --driver codex --execute requires OPENAI_API_KEY environment variable",
857
- file=sys.stderr,
858
- )
859
- sys.exit(1)
860
- driver = CodexDriver()
861
- except ImportError:
862
- print("ERROR: --driver codex requires codex_driver.py", file=sys.stderr)
863
- sys.exit(1)
864
- else:
865
- # Default: Claude Code driver
866
- try:
867
- from claude_code_driver import ClaudeCodeDriver
868
- driver = ClaudeCodeDriver()
869
- except ImportError:
870
- print("ERROR: claude_code_driver.py not found", file=sys.stderr)
871
- sys.exit(1)
1169
+ # HS-1: worker seat from config; --driver claude|codex remains an override.
1170
+ driver, driver_error = resolve_worker_driver(
1171
+ driver_override=args.driver,
1172
+ config_path=args.config,
1173
+ execute=args.execute,
1174
+ )
1175
+ if driver_error:
1176
+ print(f"ERROR: {driver_error}", file=sys.stderr)
1177
+ sys.exit(1)
1178
+
1179
+ # HS-2: orchestrator seat from config (seats.orchestrator). None = the
1180
+ # live harness stays the orchestrator (byte-identical default).
1181
+ orchestrator_backend, orch_error = resolve_orchestrator_backend(
1182
+ config_path=args.config,
1183
+ execute=args.execute,
1184
+ )
1185
+ if orch_error:
1186
+ print(f"ERROR: {orch_error}", file=sys.stderr)
1187
+ sys.exit(1)
1188
+ if orchestrator_backend is not None:
1189
+ print(
1190
+ "[wave_scheduler] orchestrator seat: "
1191
+ f"{type(orchestrator_backend).__name__} "
1192
+ f"(model={getattr(orchestrator_backend, 'model', None)})",
1193
+ file=sys.stderr,
1194
+ )
872
1195
 
873
1196
  # Run scheduler
874
1197
  report = run_wave_scheduler(
@@ -877,6 +1200,7 @@ def main():
877
1200
  dry_run=dry_run,
878
1201
  driver=driver,
879
1202
  state_dir=Path(args.state_dir) if args.state_dir else None,
1203
+ orchestrator_backend=orchestrator_backend,
880
1204
  )
881
1205
 
882
1206
  # Output report as JSON
@@ -613,6 +613,72 @@ function detectIsolationViolations() {
613
613
  return { violations, count: violations.length };
614
614
  }
615
615
 
616
+ // 11b) Main CI status polling
617
+ // Calls `gh run list` to get latest main-full workflow run status; never throws
618
+ function checkMainCiStatus() {
619
+ try {
620
+ // Execute gh command to get latest main-full workflow run
621
+ const result = execSync(
622
+ 'gh run list --workflow=main-full.yml --branch main --limit 1 --json status,conclusion,headSha,url',
623
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
624
+ ).trim();
625
+
626
+ if (!result) {
627
+ // No runs found (workflow may not have run yet)
628
+ return {
629
+ state: 'unknown',
630
+ reason: 'No main-full workflow runs found',
631
+ checked_at: new Date(now).toISOString(),
632
+ };
633
+ }
634
+
635
+ const runs = JSON.parse(result);
636
+ if (!Array.isArray(runs) || runs.length === 0) {
637
+ return {
638
+ state: 'unknown',
639
+ reason: 'No main-full workflow runs found',
640
+ checked_at: new Date(now).toISOString(),
641
+ };
642
+ }
643
+
644
+ const latestRun = runs[0];
645
+ const status = latestRun.status || '';
646
+ const conclusion = latestRun.conclusion || '';
647
+ const sha = latestRun.headSha || '';
648
+ const url = latestRun.url || '';
649
+
650
+ // Map status and conclusion to state
651
+ let state = 'unknown';
652
+ if (status === 'completed') {
653
+ if (conclusion === 'success') {
654
+ state = 'pass';
655
+ } else if (conclusion === 'failure') {
656
+ state = 'fail';
657
+ } else {
658
+ state = 'unknown';
659
+ }
660
+ } else if (status === 'in_progress') {
661
+ state = 'running';
662
+ } else {
663
+ state = 'unknown';
664
+ }
665
+
666
+ return {
667
+ state,
668
+ sha,
669
+ url,
670
+ checked_at: new Date(now).toISOString(),
671
+ };
672
+ } catch (e) {
673
+ // gh command failed, missing, or unauthed — return unknown gracefully
674
+ return {
675
+ state: 'unknown',
676
+ reason: e.message || 'gh command unavailable or failed',
677
+ checked_at: new Date(now).toISOString(),
678
+ };
679
+ }
680
+ }
681
+
616
682
  // 12) Agent stall detection
617
683
  // Calls tools/stall_check.py --json with bounded timeout; fails gracefully with NOT-AVAILABLE signal
618
684
  function checkAgentStalls() {
@@ -891,6 +957,7 @@ const gitState = checkGitState();
891
957
  const memory = checkMemoryFreshness();
892
958
  const logFiles = checkLogFiles(securityAlertsContent);
893
959
  const isolationViolations = detectIsolationViolations();
960
+ const mainCi = checkMainCiStatus();
894
961
  const agentStalls = checkAgentStalls();
895
962
 
896
963
  // Extended signal checks (5, 6, 8, 10) — skipped if extended_signals is OFF
@@ -941,6 +1008,7 @@ const signals = {
941
1008
  costTick,
942
1009
  unreviewedPrompts,
943
1010
  isolationViolations,
1011
+ main_ci: mainCi,
944
1012
  agentStalls,
945
1013
  };
946
1014
 
@@ -1015,6 +1083,21 @@ if (!agentStalls.available) {
1015
1083
  }
1016
1084
  brief.push('');
1017
1085
 
1086
+ brief.push('## Main CI status (main-full workflow)');
1087
+ if (mainCi.state === 'pass') {
1088
+ brief.push('✓ Latest main-full run passed.');
1089
+ } else if (mainCi.state === 'fail') {
1090
+ brief.push(`🚨 **Latest main-full run FAILED:**`);
1091
+ if (mainCi.sha) brief.push(` SHA: ${mainCi.sha}`);
1092
+ if (mainCi.url) brief.push(` URL: ${mainCi.url}`);
1093
+ } else if (mainCi.state === 'running') {
1094
+ brief.push('⏳ Main-full workflow currently running...');
1095
+ if (mainCi.url) brief.push(` URL: ${mainCi.url}`);
1096
+ } else {
1097
+ brief.push(`⚠ Main-full status unknown: ${mainCi.reason || 'no data available'}`);
1098
+ }
1099
+ brief.push('');
1100
+
1018
1101
  // Extended signals section (if disabled, just note they're off; if enabled, show details)
1019
1102
  if (extendedSignals) {
1020
1103
  brief.push('## Junk-script sprawl (temp/scratch)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matt82198/aesop",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "description": "Multi-agent orchestration for AI coding agents, designed to survive failure — a plain-file brain, git as the only durable layer, cheap subagent fleets, and guardrails enforced in code.",
5
5
  "bin": {
6
6
  "aesop": "bin/cli.js"