@matt82198/aesop 0.2.0 → 0.3.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 (44) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +50 -260
  3. package/bin/cli.js +7 -3
  4. package/docs/HOOK-INSTALL.md +15 -56
  5. package/docs/INSTALL.md +4 -3
  6. package/docs/PORTING.md +166 -0
  7. package/docs/TEAM-STATE.md +16 -184
  8. package/docs/reproduce.md +33 -3
  9. package/driver/CLAUDE.md +50 -48
  10. package/driver/codex_driver.py +59 -12
  11. package/driver/openai_transport.py +33 -0
  12. package/driver/wave_bridge.py +9 -1
  13. package/driver/wave_loop.py +437 -70
  14. package/driver/wave_scheduler.py +890 -0
  15. package/hooks/pre-push-policy.sh +22 -5
  16. package/monitor/collect-signals.mjs +69 -0
  17. package/package.json +4 -4
  18. package/state_store/__init__.py +2 -1
  19. package/state_store/projections.py +63 -0
  20. package/state_store/read_api.py +156 -0
  21. package/state_store/write_api.py +462 -0
  22. package/tools/cost_ceiling.py +106 -15
  23. package/tools/cost_projection.py +559 -0
  24. package/tools/crossos_drift.py +394 -0
  25. package/tools/eod_sweep.py +137 -33
  26. package/tools/mutation_test.py +130 -8
  27. package/tools/proposals.mjs +47 -2
  28. package/tools/reproduce.js +405 -0
  29. package/tools/secret_scan.py +73 -18
  30. package/tools/stall_check.py +247 -16
  31. package/tools/stateapi_lint.py +325 -0
  32. package/tools/test_battery.py +173 -0
  33. package/tools/verify_cost_panel.py +345 -0
  34. package/tools/wave_preflight.py +296 -29
  35. package/tools/wave_templates.py +170 -45
  36. package/ui/collectors.py +81 -0
  37. package/ui/handler.py +230 -85
  38. package/ui/sse.py +3 -3
  39. package/ui/wave_telemetry.py +24 -18
  40. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  41. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  42. package/ui/web/dist/index.html +2 -2
  43. package/docs/QUICKSTART.md +0 -80
  44. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -0,0 +1,890 @@
1
+ #!/usr/bin/env python3
2
+ """Wave scheduler: single-cycle orchestration of backlog intake, manifest build, and wave dispatch.
3
+
4
+ WS3a pilot: deterministic one-cycle loop with CRITICAL GUARDRAILS:
5
+ 1. Intakes up to N file-disjoint todo items from tracker.json (respects ownsFiles)
6
+ 2. Validates required fields + path normalization (platform-independent, no symlink TOCTOU)
7
+ 3. Builds a run_wave manifest via wave_templates conventions
8
+ 4. Invokes driver.wave_loop.run_wave with recovery journal + git ship config
9
+ 5. STOPS before merge: emits Report JSON for human/orchestrator review
10
+ 6. Double-dispatch prevention: write "in_progress" status to tracker.json (atomic, conflict-detecting)
11
+ 7. Bounded by: HALT file check (final gate before dispatch) + cost ceiling check
12
+
13
+ SINGLE-WRITER ASSUMPTION: This pilot assumes tracker.json is NOT edited concurrently by other
14
+ processes. Concurrent-writer safety checks detect conflicts and abort; full lock/StateAPI
15
+ integration is filed for next wave. Do NOT run multiple schedulers against the same tracker.
16
+
17
+ CLI: python driver/wave_scheduler.py --tracker <path> --max-items N --dry-run|--execute
18
+
19
+ stdlib-only, ASCII-only, Windows + Linux safe.
20
+ """
21
+
22
+ import argparse
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import sys
27
+ import tempfile
28
+ import uuid
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Dict, List, Any, Optional, Set, Tuple
32
+
33
+ # Add driver/ and tools/ to path
34
+ REPO = Path(__file__).resolve().parent.parent
35
+ DRIVER_DIR = REPO / "driver"
36
+ TOOLS_DIR = REPO / "tools"
37
+ if str(DRIVER_DIR) not in sys.path:
38
+ sys.path.insert(0, str(DRIVER_DIR))
39
+ if str(TOOLS_DIR) not in sys.path:
40
+ sys.path.insert(0, str(TOOLS_DIR))
41
+
42
+ # Import core modules
43
+ from wave_loop import run_wave
44
+ from agent_driver import AgentDriver
45
+ from verification_policy import verification_policy
46
+
47
+ # Import safety gates (P1-3: fail-closed if unavailable)
48
+ try:
49
+ import halt
50
+ except ImportError:
51
+ halt = None
52
+
53
+ try:
54
+ import cost_ceiling
55
+ except ImportError:
56
+ cost_ceiling = None
57
+
58
+ try:
59
+ from common import get_state_dir
60
+ except ImportError:
61
+ from tools.common import get_state_dir
62
+
63
+
64
+ # ========================================================================
65
+ # Path Normalization (P1-2: PLATFORM-INDEPENDENT)
66
+ # ========================================================================
67
+
68
+ def _normalize_path(path: str) -> str:
69
+ """Normalize a path for comparison: posixify, strip ./, casefold ALWAYS (P1-2).
70
+
71
+ CRITICAL: Casefolding ALWAYS (not just on Windows) ensures platform-independent
72
+ ownership semantics — same tracker selects identically on all OS.
73
+
74
+ Args:
75
+ path: file path (potentially with backslashes, ./ prefix)
76
+
77
+ Returns:
78
+ normalized path (forward slashes, no leading ./, lowercased)
79
+ """
80
+ # Replace backslashes with forward slashes (posixify)
81
+ normalized = path.replace("\\", "/")
82
+
83
+ # Strip leading ./
84
+ if normalized.startswith("./"):
85
+ normalized = normalized[2:]
86
+
87
+ # Casefold ALWAYS for platform-independent semantics (P1-2)
88
+ normalized = normalized.lower()
89
+
90
+ return normalized
91
+
92
+
93
+ def _is_valid_owned_path(path: str) -> bool:
94
+ """Validate an ownsFiles entry: reject absolute paths and traversal attacks (P5).
95
+
96
+ Args:
97
+ path: normalized file path
98
+
99
+ Returns:
100
+ True iff path is relative, has no .. traversal, and is safe to dispatch
101
+ """
102
+ # Reject absolute paths (starting with /)
103
+ if path.startswith("/"):
104
+ return False
105
+
106
+ # Reject traversal attacks (.. after normalization)
107
+ if ".." in path:
108
+ return False
109
+
110
+ return True
111
+
112
+
113
+ # ========================================================================
114
+ # Tracker & Manifest Loading
115
+ # ========================================================================
116
+
117
+ def load_tracker_items(tracker_path: str) -> List[Dict[str, Any]]:
118
+ """Load tracker.json items.
119
+
120
+ Args:
121
+ tracker_path: absolute path to tracker.json
122
+
123
+ Returns:
124
+ list of item dicts, or [] if file missing/invalid
125
+ """
126
+ p = Path(tracker_path)
127
+ if not p.exists():
128
+ return []
129
+ try:
130
+ with open(p, "r", encoding="utf-8") as f:
131
+ data = json.load(f)
132
+ # Handle both {"items": [...]} and [...]
133
+ if isinstance(data, dict):
134
+ return data.get("items", [])
135
+ return data if isinstance(data, list) else []
136
+ except (json.JSONDecodeError, IOError):
137
+ return []
138
+
139
+
140
+ def _read_tracker_with_hash(tracker_path: str) -> Tuple[List[Dict], Optional[str]]:
141
+ """Load tracker.json and compute content hash for conflict detection (P6).
142
+
143
+ Returns:
144
+ (items_list, content_hash)
145
+ """
146
+ items = load_tracker_items(tracker_path)
147
+ p = Path(tracker_path)
148
+
149
+ if p.exists():
150
+ try:
151
+ with open(p, "rb") as f:
152
+ content_hash = hashlib.sha256(f.read()).hexdigest()
153
+ except IOError:
154
+ content_hash = None
155
+ else:
156
+ content_hash = None
157
+
158
+ return items, content_hash
159
+
160
+
161
+ def _validate_item(item: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
162
+ """Validate required fields and ownsFiles for an item (P1-6, P5).
163
+
164
+ Args:
165
+ item: tracker item dict
166
+
167
+ Returns:
168
+ (is_valid: bool, error_reason: str|None)
169
+ """
170
+ # Check ownsFiles first (P1-1: special handling for empty)
171
+ owns = item.get("ownsFiles")
172
+ if not owns or (isinstance(owns, list) and len(owns) == 0):
173
+ return False, "no_file_ownership"
174
+
175
+ # Ensure all entries in ownsFiles are non-empty strings and valid paths (P5)
176
+ if isinstance(owns, list):
177
+ for entry in owns:
178
+ if not isinstance(entry, str) or not entry:
179
+ return False, "invalid_ownsFiles_entries"
180
+ # Normalize and validate (reject absolute paths, traversal)
181
+ normalized = _normalize_path(entry)
182
+ if not _is_valid_owned_path(normalized):
183
+ return False, "invalid_path"
184
+
185
+ # Check other required fields
186
+ required = ["slug", "prompt", "testCmd"]
187
+ for field in required:
188
+ if field not in item or not item[field]:
189
+ return False, f"missing_or_empty_{field}"
190
+
191
+ return True, None
192
+
193
+
194
+ def filter_todo_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
195
+ """Filter items to only status=todo, sorted by priority then creation date.
196
+
197
+ Priority order: P1 > P2 > P3
198
+ Within same priority: oldest first
199
+ """
200
+ todo = [item for item in items if item.get("status") == "todo"]
201
+
202
+ # Sort by priority (P1=0, P2=1, P3=2), then by createdAt
203
+ def priority_rank(item):
204
+ prio = item.get("priority", "P3")
205
+ rank = {"P1": 0, "P2": 1, "P3": 2}.get(prio, 2)
206
+ created = item.get("createdAt", "2999-01-01")
207
+ return (rank, created)
208
+
209
+ return sorted(todo, key=priority_rank)
210
+
211
+
212
+ def select_disjoint_items(
213
+ items: List[Dict[str, Any]], max_count: int
214
+ ) -> Tuple[List[Dict[str, Any]], List[str]]:
215
+ """Greedily select up to max_count items with no file overlap (P1-1, P1-2).
216
+
217
+ Greedy algorithm: sort by (file_count, priority), pick items that don't
218
+ overlap ownsFiles (after normalization) with already-selected items.
219
+
220
+ Returns:
221
+ (selected_items, skipped_item_ids) — skipped due to overlap
222
+ """
223
+ selected = []
224
+ used_files: Set[str] = set()
225
+ skipped = []
226
+
227
+ # Sort by file count (ascending) to pack smaller items first
228
+ def item_sort_key(item):
229
+ files = item.get("ownsFiles", [])
230
+ prio = item.get("priority", "P3")
231
+ rank = {"P1": 0, "P2": 1, "P3": 2}.get(prio, 2)
232
+ return (len(files), rank)
233
+
234
+ items_to_process = sorted(items, key=item_sort_key)
235
+
236
+ for item in items_to_process:
237
+ if len(selected) >= max_count:
238
+ break
239
+
240
+ owns = [_normalize_path(f) for f in item.get("ownsFiles", [])]
241
+ owns_set = set(owns)
242
+
243
+ # Check for overlap
244
+ if owns_set & used_files:
245
+ skipped.append(item.get("id", "unknown"))
246
+ continue
247
+
248
+ # No overlap, select it
249
+ selected.append(item)
250
+ used_files.update(owns_set)
251
+
252
+ return selected, skipped
253
+
254
+
255
+ def _check_halt_file(state_dir: Optional[Path] = None) -> Tuple[bool, Optional[str]]:
256
+ """Check if .HALT file exists (P1-3, P1-4).
257
+
258
+ Returns:
259
+ (is_halted: bool, reason: str|None)
260
+
261
+ Raises:
262
+ RuntimeError if halt module is unavailable or check fails
263
+ """
264
+ if halt is None:
265
+ raise RuntimeError("halt module unavailable (import failed)")
266
+
267
+ try:
268
+ if halt.is_halted(state_dir):
269
+ info = halt.get_halt_info(state_dir)
270
+ reason = info.get("reason", "Unknown halt") if info else "Unknown halt"
271
+ return True, reason
272
+ except Exception as e:
273
+ # P1-4: gate check error = halt, not pass
274
+ raise RuntimeError(f"halt file check failed: {e}")
275
+
276
+ return False, None
277
+
278
+
279
+ def _check_cost_ceiling(state_dir: Optional[Path] = None) -> Tuple[bool, Optional[str]]:
280
+ """Check cost ceiling (P1-3, P1-4, P2a).
281
+
282
+ Returns:
283
+ (ceiling_exceeded: bool, reason: str|None)
284
+
285
+ Raises:
286
+ RuntimeError if cost_ceiling module is unavailable or check fails
287
+ """
288
+ if cost_ceiling is None:
289
+ raise RuntimeError("cost_ceiling module unavailable (import failed)")
290
+
291
+ try:
292
+ # P2a: use trip=False to enforce (fail-closed on exceeded)
293
+ result = cost_ceiling.check(spent=None, period="wave", state_dir=state_dir, trip=False)
294
+ if result.get("exceeded"):
295
+ return True, f"Cost ceiling exceeded: {result.get('spent', 0)}/{result.get('ceiling', 0)} tokens"
296
+ except Exception as e:
297
+ # P1-4: gate check error = halt, not pass
298
+ raise RuntimeError(f"cost ceiling check failed: {e}")
299
+
300
+ return False, None
301
+
302
+
303
+ def _verify_gate_availability() -> Tuple[bool, Optional[str]]:
304
+ """Pre-flight gate availability check (P1-3).
305
+
306
+ Returns:
307
+ (gates_available: bool, error_reason: str|None)
308
+ """
309
+ if halt is None:
310
+ return False, "halt module unavailable"
311
+ if cost_ceiling is None:
312
+ return False, "cost_ceiling module unavailable"
313
+ return True, None
314
+
315
+
316
+ # ========================================================================
317
+ # Manifest Building
318
+ # ========================================================================
319
+
320
+ def build_wave_manifest(
321
+ selected_items: List[Dict[str, Any]], driver: AgentDriver
322
+ ) -> Tuple[Dict[str, Any], List[str]]:
323
+ """Build a wave manifest from selected tracker items (P1-6).
324
+
325
+ Returns:
326
+ (manifest_dict, items_failed_build_ids)
327
+
328
+ Items that fail to build are excluded from manifest and reported separately.
329
+ """
330
+ try:
331
+ from wave_bridge import build_manifest_item
332
+ except ImportError:
333
+ from driver.wave_bridge import build_manifest_item
334
+
335
+ manifest_items = []
336
+ failed_ids = []
337
+
338
+ for item in selected_items:
339
+ try:
340
+ m_item = build_manifest_item(driver, item)
341
+ manifest_items.append(m_item)
342
+ except Exception as e:
343
+ # P1-6: failed builds are recorded, not selected
344
+ failed_ids.append(item.get("id", "unknown"))
345
+ print(f"[wave_scheduler] Failed to build manifest for {item.get('id')}: {e}", file=sys.stderr)
346
+ continue
347
+
348
+ wave_id = str(uuid.uuid4())
349
+ return (
350
+ {
351
+ "wave_id": wave_id,
352
+ "items": manifest_items,
353
+ "wave_description": f"WS3a pilot wave {wave_id[:8]}",
354
+ },
355
+ failed_ids,
356
+ )
357
+
358
+
359
+ # ========================================================================
360
+ # Tracker Update (P1-5, P6: ATOMIC WITH CONFLICT DETECTION)
361
+ # ========================================================================
362
+
363
+ def _write_tracker_status_atomic(
364
+ tracker_path: str, items_to_update: List[str], new_status: str, wave_id: str,
365
+ expected_hash: Optional[str] = None,
366
+ ) -> Tuple[bool, Optional[str]]:
367
+ """Write item status updates to tracker.json atomically (P1-5, P6, HIGH).
368
+
369
+ Uses tempfile.NamedTemporaryFile + os.replace for atomicity and TOCTOU safety.
370
+ Detects concurrent writes via content-hash comparison.
371
+
372
+ Args:
373
+ tracker_path: path to tracker.json
374
+ items_to_update: list of item IDs to mark "in_progress"
375
+ new_status: new status (should be "in_progress" for pilot)
376
+ wave_id: wave ID to record in notes
377
+ expected_hash: content hash from intake; if current content differs, abort with conflict
378
+
379
+ Returns:
380
+ (success: bool, error_reason: str|None)
381
+ """
382
+ try:
383
+ # Load current tracker
384
+ p = Path(tracker_path)
385
+ if not p.exists():
386
+ return True, None # No tracker to update (dry-run or first pass)
387
+
388
+ # P6: Detect concurrent writes (conflict detection)
389
+ if expected_hash:
390
+ try:
391
+ with open(p, "rb") as f:
392
+ current_content = f.read()
393
+ current_hash = hashlib.sha256(current_content).hexdigest()
394
+ if current_hash != expected_hash:
395
+ return False, "tracker_conflict"
396
+ except IOError:
397
+ return False, "tracker_conflict"
398
+
399
+ # Load tracker for mutation
400
+ with open(p, "r", encoding="utf-8") as f:
401
+ data = json.load(f)
402
+
403
+ # Normalize to items list
404
+ if isinstance(data, dict):
405
+ items = data.get("items", [])
406
+ elif isinstance(data, list):
407
+ items = data
408
+ else:
409
+ return False, "invalid_tracker_format"
410
+
411
+ # Update items
412
+ items_to_update_set = set(items_to_update)
413
+ for item in items:
414
+ if item.get("id") in items_to_update_set:
415
+ item["status"] = new_status
416
+ notes = item.get("notes", "")
417
+ item["notes"] = f"{notes} [wave {wave_id[:8]}]".strip()
418
+
419
+ # P1-5, HIGH: Write atomically using tempfile.NamedTemporaryFile (TOCTOU safe)
420
+ # Do NOT use predictable .tmp suffix (symlink vulnerability)
421
+ temp_fd = None
422
+ try:
423
+ temp_fd, temp_path = tempfile.mkstemp(dir=p.parent, prefix=".tracker-", suffix=".tmp")
424
+ temp_path = Path(temp_path)
425
+
426
+ # Write to temp file
427
+ with os.fdopen(temp_fd, "w", encoding="utf-8") as f:
428
+ if isinstance(data, dict):
429
+ data["items"] = items
430
+ json.dump(data, f, indent=2)
431
+ else:
432
+ json.dump(items, f, indent=2)
433
+ temp_fd = None
434
+
435
+ # Atomic replace
436
+ os.replace(temp_path, p)
437
+ return True, None
438
+
439
+ except Exception as e:
440
+ if temp_fd is not None:
441
+ os.close(temp_fd)
442
+ # Clean up temp file if it exists
443
+ try:
444
+ temp_path.unlink(missing_ok=True)
445
+ except Exception:
446
+ pass
447
+ return False, f"Failed to update tracker: {e}"
448
+
449
+ except Exception as e:
450
+ return False, f"Failed to update tracker: {e}"
451
+
452
+
453
+ # ========================================================================
454
+ # Reporting
455
+ # ========================================================================
456
+
457
+ def emit_report(
458
+ phase: str,
459
+ wave_id: str,
460
+ items_selected: List[str],
461
+ items_shipped: Optional[List[Dict[str, Any]]] = None,
462
+ items_failed_build: Optional[List[str]] = None,
463
+ items_skipped: Optional[List[Dict[str, str]]] = None,
464
+ branch: Optional[str] = None,
465
+ sha: Optional[str] = None,
466
+ halt_reason: Optional[str] = None,
467
+ ceiling_reason: Optional[str] = None,
468
+ error: Optional[str] = None,
469
+ tracker_update_error: Optional[str] = None,
470
+ tracker_update_attempted: bool = False,
471
+ tracker_unmapped_slugs: Optional[List[str]] = None,
472
+ success: bool = False,
473
+ merged: bool = False,
474
+ ) -> Dict[str, Any]:
475
+ """Emit a Report JSON structure (GATE-1 HANDOFF KIT).
476
+
477
+ Per-item observability: items_shipped includes full details {slug, backend, tier, verified, testExit}.
478
+
479
+ Returns:
480
+ report dict (ready to serialize)
481
+ """
482
+ report = {
483
+ "phase": phase,
484
+ "wave_id": wave_id,
485
+ "items_selected": items_selected,
486
+ "items_shipped": items_shipped or [],
487
+ "merged": merged, # P2c: explicit merged=false in pilot
488
+ "timestamp": datetime.now(timezone.utc).isoformat(),
489
+ "success": success,
490
+ }
491
+
492
+ if items_failed_build:
493
+ report["items_failed_build"] = items_failed_build
494
+ if items_skipped:
495
+ report["items_skipped"] = items_skipped
496
+ if branch:
497
+ report["branch"] = branch
498
+ if sha:
499
+ report["sha"] = sha
500
+ if halt_reason:
501
+ report["halt_reason"] = halt_reason
502
+ if ceiling_reason:
503
+ report["ceiling_reason"] = ceiling_reason
504
+ if error:
505
+ report["error"] = error
506
+ if tracker_update_error:
507
+ report["tracker_update_error"] = tracker_update_error
508
+ if tracker_update_attempted:
509
+ report["tracker_update_attempted"] = True
510
+ if tracker_unmapped_slugs:
511
+ report["tracker_unmapped_slugs"] = tracker_unmapped_slugs
512
+
513
+ return report
514
+
515
+
516
+ # ========================================================================
517
+ # Main Orchestrator
518
+ # ========================================================================
519
+
520
+ def run_wave_scheduler(
521
+ tracker_path: str,
522
+ max_items: int = 5,
523
+ dry_run: bool = False,
524
+ driver: Optional[AgentDriver] = None,
525
+ state_dir: Optional[Path] = None,
526
+ ) -> Dict[str, Any]:
527
+ """Run one complete wave cycle (intake -> manifest -> dispatch -> report).
528
+
529
+ Args:
530
+ tracker_path: path to tracker.json
531
+ max_items: max items to select
532
+ dry_run: if True, print manifest without dispatch
533
+ driver: AgentDriver instance (defaults to FakeDriver for testing)
534
+ state_dir: state directory (defaults to ./state)
535
+
536
+ Returns:
537
+ Report dict (phase, wave_id, items_selected, items_shipped, etc.)
538
+ """
539
+ if state_dir is None:
540
+ try:
541
+ state_dir = get_state_dir()
542
+ except Exception:
543
+ state_dir = Path("./state")
544
+
545
+ state_dir = Path(state_dir)
546
+ wave_id = str(uuid.uuid4())
547
+
548
+ # ====== PHASE 0: GATE AVAILABILITY CHECK (P1-3) ======
549
+ gates_ok, gate_error = _verify_gate_availability()
550
+ if not gates_ok:
551
+ return emit_report(
552
+ phase="gate_unavailable",
553
+ wave_id=wave_id,
554
+ items_selected=[],
555
+ error=gate_error,
556
+ success=False,
557
+ )
558
+
559
+ # ====== PHASE 1: HALT CHECK ======
560
+ try:
561
+ is_halted, halt_reason = _check_halt_file(state_dir)
562
+ except RuntimeError as e:
563
+ return emit_report(
564
+ phase="halt",
565
+ wave_id=wave_id,
566
+ items_selected=[],
567
+ error=str(e),
568
+ success=False,
569
+ )
570
+
571
+ if is_halted:
572
+ return emit_report(
573
+ phase="halt",
574
+ wave_id=wave_id,
575
+ items_selected=[],
576
+ halt_reason=halt_reason,
577
+ success=False,
578
+ )
579
+
580
+ # ====== PHASE 2: INTAKE + VALIDATION (P1-6, P5) ======
581
+ # P6: Read tracker with hash for conflict detection
582
+ all_items, intake_hash = _read_tracker_with_hash(tracker_path)
583
+ todo_items = filter_todo_items(all_items)
584
+
585
+ # Separate valid from invalid items
586
+ valid_items = []
587
+ items_skipped = []
588
+ for item in todo_items:
589
+ is_valid, error_reason = _validate_item(item)
590
+ if is_valid:
591
+ valid_items.append(item)
592
+ else:
593
+ items_skipped.append({"id": item.get("id", "unknown"), "reason": error_reason})
594
+
595
+ if not valid_items:
596
+ return emit_report(
597
+ phase="intake",
598
+ wave_id=wave_id,
599
+ items_selected=[],
600
+ items_skipped=items_skipped if items_skipped else None,
601
+ success=True,
602
+ )
603
+
604
+ # ====== PHASE 3: DISJOINT SELECTION ======
605
+ selected_items, skipped_ids = select_disjoint_items(valid_items, max_items)
606
+
607
+ if not selected_items:
608
+ return emit_report(
609
+ phase="intake",
610
+ wave_id=wave_id,
611
+ items_selected=[],
612
+ items_skipped=items_skipped if items_skipped else None,
613
+ success=True,
614
+ )
615
+
616
+ selected_ids = [item.get("id", "unknown") for item in selected_items]
617
+
618
+ # ====== PHASE 4: MANIFEST BUILD (P1-6) ======
619
+ if driver is None:
620
+ from tests.test_wave_loop import FakeDriver
621
+ driver = FakeDriver()
622
+
623
+ try:
624
+ manifest, failed_build_ids = build_wave_manifest(selected_items, driver)
625
+ except Exception as e:
626
+ return emit_report(
627
+ phase="manifest",
628
+ wave_id=wave_id,
629
+ items_selected=selected_ids,
630
+ error=str(e),
631
+ success=False,
632
+ )
633
+
634
+ # Remove failed items from selected
635
+ if failed_build_ids:
636
+ selected_ids = [id for id in selected_ids if id not in failed_build_ids]
637
+
638
+ # ====== PHASE 5: DRY-RUN CHECK ======
639
+ if dry_run:
640
+ return emit_report(
641
+ phase="manifest",
642
+ wave_id=wave_id,
643
+ items_selected=selected_ids,
644
+ items_failed_build=failed_build_ids if failed_build_ids else None,
645
+ success=True,
646
+ )
647
+
648
+ # ====== PHASE 6: COST CEILING CHECK (P2b: before final HALT) ======
649
+ try:
650
+ ceiling_exceeded, ceiling_reason = _check_cost_ceiling(state_dir)
651
+ except RuntimeError as e:
652
+ return emit_report(
653
+ phase="ceiling",
654
+ wave_id=wave_id,
655
+ items_selected=selected_ids,
656
+ error=str(e),
657
+ success=False,
658
+ )
659
+
660
+ if ceiling_exceeded:
661
+ return emit_report(
662
+ phase="ceiling",
663
+ wave_id=wave_id,
664
+ items_selected=selected_ids,
665
+ ceiling_reason=ceiling_reason,
666
+ success=False,
667
+ )
668
+
669
+ # ====== PHASE 7: FINAL HALT CHECK (P2b, P4: immediately before dispatch) ======
670
+ try:
671
+ is_halted, halt_reason = _check_halt_file(state_dir)
672
+ except RuntimeError as e:
673
+ return emit_report(
674
+ phase="halt",
675
+ wave_id=wave_id,
676
+ items_selected=selected_ids,
677
+ error=str(e),
678
+ success=False,
679
+ )
680
+
681
+ if is_halted:
682
+ return emit_report(
683
+ phase="halt",
684
+ wave_id=wave_id,
685
+ items_selected=selected_ids,
686
+ halt_reason=halt_reason,
687
+ success=False,
688
+ )
689
+
690
+ # ====== PHASE 8: RUN WAVE ======
691
+ try:
692
+ state_dir.mkdir(parents=True, exist_ok=True)
693
+
694
+ wave_result = run_wave(
695
+ driver=driver,
696
+ manifest=manifest,
697
+ state_dir=state_dir,
698
+ git={"expectTopLevel": str(REPO)},
699
+ resume_journal=True,
700
+ )
701
+
702
+ # P2c: verify no merged=True, record merged=false
703
+ # GATE-1: per-item observability {slug, backend, tier, verified, testExit}.
704
+ # REAL run_wave shape (live-pilot fix): "shipped" is a list of SLUG
705
+ # STRINGS; the per-item records live in "built". Join them.
706
+ backend_name = driver.probe_capabilities().name
707
+ built_by_slug = {
708
+ b.get("slug"): b for b in (wave_result.get("built") or []) if isinstance(b, dict)
709
+ }
710
+ items_shipped = []
711
+ shipped_slugs = []
712
+ for slug in wave_result.get("shipped", []) or []:
713
+ if isinstance(slug, dict): # tolerate dict-shaped fakes
714
+ slug = slug.get("slug", "unknown")
715
+ shipped_slugs.append(slug)
716
+ b = built_by_slug.get(slug, {})
717
+ items_shipped.append({
718
+ "slug": slug,
719
+ "backend": backend_name,
720
+ # tier None = no build record (unknown), never a fabricated 1.
721
+ "tier": b.get("verificationTier") if b else None,
722
+ # verified False = NOT PROVEN (conservative), see REPORT-CONTRACT.
723
+ "verified": b.get("verified", False),
724
+ "testExit": b.get("testExit"),
725
+ "buildRecord": bool(b),
726
+ })
727
+
728
+ # TRACKER UPDATE FIRST (live-pilot lesson: a crash in Report assembly
729
+ # must never leave shipped-but-unmarked items). Attempted as soon as
730
+ # shipped_slugs is known; outcome fields survive into ANY report,
731
+ # including the exception envelope below.
732
+ tracker_update_attempted = False
733
+ tracker_update_error = None
734
+ tracker_unmapped = []
735
+ if shipped_slugs:
736
+ tracker_update_attempted = True
737
+ try:
738
+ slug_to_id = {it.get("slug"): it.get("id") for it in selected_items}
739
+ shipped_item_ids = []
740
+ for s_ in shipped_slugs:
741
+ id_ = slug_to_id.get(s_)
742
+ if id_:
743
+ shipped_item_ids.append(id_)
744
+ else:
745
+ # LOUD, never silent: an unmapped shipped slug means the
746
+ # tracker cannot be marked -> double-dispatch risk.
747
+ tracker_unmapped.append(s_)
748
+ if shipped_item_ids:
749
+ success_update, update_error = _write_tracker_status_atomic(
750
+ tracker_path,
751
+ shipped_item_ids,
752
+ "in_progress",
753
+ wave_id,
754
+ expected_hash=intake_hash, # P6: conflict detection
755
+ )
756
+ if not success_update:
757
+ tracker_update_error = update_error
758
+ if tracker_unmapped and tracker_update_error is None:
759
+ tracker_update_error = "unmapped_shipped_slugs"
760
+ except Exception as te:
761
+ tracker_update_error = f"tracker_update_exception: {te}"
762
+
763
+ # Ship sha comes from the per-repo ship results (no top-level sha key).
764
+ repo_results = wave_result.get("shipped_repos") or []
765
+ sha = next((r.get("sha") for r in repo_results if isinstance(r, dict) and r.get("sha")), None)
766
+ branch = None # run_wave ships on the current branch; scheduler does not switch branches
767
+
768
+ # run_wave has NO top-level "success" key: derive honestly. success
769
+ # additionally requires EVERY shipped item verified (contract: a
770
+ # shipped-but-unproven item is not a successful wave).
771
+ wave_ok = bool(wave_result.get("preflight_ok")) and not wave_result.get("aborted")
772
+ all_verified = all(i.get("verified") for i in items_shipped) if items_shipped else True
773
+ return emit_report(
774
+ phase="dispatch",
775
+ wave_id=wave_id,
776
+ items_selected=selected_ids,
777
+ items_shipped=items_shipped,
778
+ items_failed_build=failed_build_ids if failed_build_ids else None,
779
+ branch=branch,
780
+ sha=sha,
781
+ tracker_update_attempted=tracker_update_attempted,
782
+ tracker_update_error=tracker_update_error,
783
+ tracker_unmapped_slugs=tracker_unmapped if tracker_unmapped else None,
784
+ success=wave_ok and all_verified and tracker_update_error is None,
785
+ merged=False, # P2c: pilot stops before merge
786
+ )
787
+
788
+ except Exception as e:
789
+ # The exception envelope must never hide ship-vs-tracker divergence:
790
+ # carry whatever tracker outcome was reached before the crash.
791
+ return emit_report(
792
+ phase="dispatch",
793
+ wave_id=wave_id,
794
+ items_selected=selected_ids,
795
+ error=str(e),
796
+ tracker_update_attempted=locals().get("tracker_update_attempted", False),
797
+ tracker_update_error=locals().get("tracker_update_error"),
798
+ success=False,
799
+ merged=False,
800
+ )
801
+
802
+
803
+ # ========================================================================
804
+ # CLI
805
+ # ========================================================================
806
+
807
+ def main():
808
+ """CLI entry point (GATE-1: driver injection, --driver claude|codex)."""
809
+ parser = argparse.ArgumentParser(
810
+ description="Wave scheduler: intake -> manifest -> dispatch -> report"
811
+ )
812
+ parser.add_argument(
813
+ "--tracker",
814
+ required=True,
815
+ help="Path to tracker.json",
816
+ )
817
+ parser.add_argument(
818
+ "--max-items",
819
+ type=int,
820
+ default=5,
821
+ help="Maximum items to select (default: 5)",
822
+ )
823
+ parser.add_argument(
824
+ "--dry-run",
825
+ action="store_true",
826
+ help="Print manifest without dispatch",
827
+ )
828
+ parser.add_argument(
829
+ "--execute",
830
+ action="store_true",
831
+ help="Execute the wave (default: dry-run)",
832
+ )
833
+ parser.add_argument(
834
+ "--state-dir",
835
+ help="State directory (default: ./state)",
836
+ )
837
+ parser.add_argument(
838
+ "--driver",
839
+ choices=["claude", "codex"],
840
+ default="claude",
841
+ help="Backend driver (claude|codex, default: claude)",
842
+ )
843
+
844
+ args = parser.parse_args()
845
+
846
+ dry_run = not args.execute
847
+
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)
872
+
873
+ # Run scheduler
874
+ report = run_wave_scheduler(
875
+ tracker_path=args.tracker,
876
+ max_items=args.max_items,
877
+ dry_run=dry_run,
878
+ driver=driver,
879
+ state_dir=Path(args.state_dir) if args.state_dir else None,
880
+ )
881
+
882
+ # Output report as JSON
883
+ print(json.dumps(report, indent=2))
884
+
885
+ # Exit with success/failure
886
+ sys.exit(0 if report.get("success") else 1)
887
+
888
+
889
+ if __name__ == "__main__":
890
+ main()