@matt82198/aesop 0.1.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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,1408 @@
1
+ #!/usr/bin/env python3
2
+ """Wave loop engine: orchestrates a full multi-item wave through AgentDriver backends.
3
+
4
+ This module implements Step 3 of the driver integration plan: a Python wave engine
5
+ that mirrors the phase sequence from wave-flat-dispatch.template.mjs but runs
6
+ offline against AgentDriver backends (Claude Code, Codex, open-model, etc.).
7
+
8
+ Phases (mirror the template):
9
+ 1. Preflight ownership guard: check no two items share an ownsFiles path (per-repo)
10
+ 2. Resolve policy ONCE: call verification_policy(caps) and use the returned
11
+ knobs for repair_cap, spot_check_frac, require_adversarial_review
12
+ 3. Cost-ceiling gate (fail-closed): before build and before each repair round,
13
+ check spend against ceiling; abort if tripped
14
+ 4. Build (PARALLEL): use ThreadPoolExecutor to dispatch items concurrently,
15
+ running each item's test, honoring disjoint ownership
16
+ 5. Bounded repair: for failed items, retry with test output appended to prompt,
17
+ up to policy's repair_cap rounds
18
+ 6. Adversarial review: if required, dispatch a review per item or mark deferred
19
+ 7. Per-repo ship: if git config given, group items by repo and run the git
20
+ sequence (add [repo-relative files], commit, push) separately for each repo,
21
+ with expectTopLevel guard verified PER REPO before any write
22
+
23
+ PHASE 1 (CROSS-REPO) SCOPE:
24
+ - Manifest items support optional `repo` field (absolute path, must exist, resolved)
25
+ - Preflight validates repo exists and rejects non-absolute paths (fail-closed)
26
+ - Ownership disjointness is per-repo (same file in different repos is NOT a conflict)
27
+ - Ship phase respects repo boundaries: one commit per repo, each repo's cwd
28
+ - Journal keys include repo context (safe-slug of repo basename + item slug)
29
+ - Shipped items reported per-repo in the Report JSON
30
+ - Phase-2 (future): per-repo secret-scan gate, per-repo branch rules, multi-box state
31
+
32
+ HONESTY GUARANTEE:
33
+ - Verified = True ONLY if the item's test passed (exit code 0 from run_command).
34
+ - Any exception -> item.verified = False, never a false green.
35
+ - Ownership is enforced at the driver level (dispatch_worker rejects out-of-scope).
36
+ - Adversarial review is NOT yet enforced; marked as 'deferred' (TODO in a later increment).
37
+
38
+ FAIL-SAFE:
39
+ - Cost-ceiling check: if exceeded, ABORT the wave immediately (return early).
40
+ - Disjoint ownership: any overlap -> ABORT with structured error, no dispatch.
41
+ - Repair cap bounded: never infinite retry loop.
42
+ - Ship phase: per-repo expectTopLevel guard aborts THAT REPO's ship without corrupting others.
43
+
44
+ stdlib-only, ASCII-only, Windows + Linux safe.
45
+ """
46
+
47
+ import concurrent.futures
48
+ import hashlib
49
+ import json
50
+ import os
51
+ import posixpath
52
+ import re
53
+ import shlex
54
+ import sys
55
+ import threading
56
+ import time
57
+ import uuid
58
+ from math import ceil
59
+ from pathlib import Path
60
+ from typing import Optional, Dict, Any, List, Tuple
61
+
62
+ # Add driver/ to path for imports.
63
+ REPO = Path(__file__).resolve().parent.parent
64
+ DRIVER_DIR = REPO / "driver"
65
+ if str(DRIVER_DIR) not in sys.path:
66
+ sys.path.insert(0, str(DRIVER_DIR))
67
+
68
+ from agent_driver import AgentDriver
69
+ from wave_bridge import build_manifest_item, dispatch_item
70
+ from verification_policy import verification_policy
71
+
72
+ # Try to import cost_ceiling and coordination (optional, for safety gates).
73
+ try:
74
+ import sys
75
+ TOOLS_DIR = REPO / "tools"
76
+ if str(TOOLS_DIR) not in sys.path:
77
+ sys.path.insert(0, str(TOOLS_DIR))
78
+ import cost_ceiling
79
+ except ImportError:
80
+ cost_ceiling = None
81
+
82
+ try:
83
+ STATE_STORE_DIR = REPO / "state_store"
84
+ if str(STATE_STORE_DIR) not in sys.path:
85
+ sys.path.insert(0, str(STATE_STORE_DIR))
86
+ import coordination
87
+ except ImportError:
88
+ coordination = None
89
+
90
+
91
+ # ========================================================================
92
+ # Sanitization and Security
93
+ # ========================================================================
94
+
95
+ def _quote_arg(s: str) -> str:
96
+ """Quote an argument for safe shell execution across Windows and POSIX.
97
+
98
+ On Windows (cmd.exe), single quotes don't quote; shlex.quote (POSIX-only)
99
+ is unsafe. This function uses subprocess.list2cmdline semantics for Windows
100
+ and shlex.quote for POSIX systems.
101
+
102
+ The durable fix is to refactor run_command to accept a list of arguments
103
+ instead of shell=True strings (deferred).
104
+
105
+ Args:
106
+ s: the string to quote for shell execution
107
+
108
+ Returns:
109
+ str: properly quoted argument safe for shell execution on this OS
110
+ """
111
+ if os.name == 'nt':
112
+ # Windows (cmd.exe): use subprocess.list2cmdline semantics.
113
+ # Double quotes are the safe quoting mechanism; embed quotes are escaped
114
+ # with backslash, and backslashes before quotes are escaped.
115
+ # For safety, we wrap in double quotes and escape embedded quotes/backslashes.
116
+ if not s:
117
+ return '""'
118
+ # Escape backslashes before quotes, then escape quotes
119
+ escaped = s.replace('\\', '\\\\').replace('"', '\\"')
120
+ return f'"{escaped}"'
121
+ else:
122
+ # POSIX: shlex.quote handles all cases safely
123
+ return shlex.quote(s)
124
+
125
+
126
+ def _validate_repo_path(repo: str) -> str:
127
+ """Validate and normalize a repo path to absolute.
128
+
129
+ Rejects relative paths, symlink escapes, and non-existent paths (fail-closed).
130
+ Returns the absolute, normalized path if valid.
131
+
132
+ Args:
133
+ repo: the repo path to validate
134
+
135
+ Returns:
136
+ str: absolute, normalized repo path
137
+
138
+ Raises:
139
+ ValueError: if repo is relative, contains .. escape, or path issues
140
+ """
141
+ repo_path = Path(repo).resolve()
142
+
143
+ # Ensure the resolved path is absolute (should always be true after resolve())
144
+ if not repo_path.is_absolute():
145
+ raise ValueError(f"repo path must be absolute: {repo}")
146
+
147
+ # Verify the path exists (fail-closed)
148
+ if not repo_path.exists():
149
+ raise ValueError(f"repo path does not exist: {repo}")
150
+
151
+ return str(repo_path)
152
+
153
+
154
+ def _validate_file_path(file_path: str, repo_root: str) -> None:
155
+ """Validate a file path for safety before git operations.
156
+
157
+ Ensures the path is:
158
+ 1. Relative (not absolute)
159
+ 2. After joining with repo root and resolving, still inside that repo root (no traversal)
160
+
161
+ Args:
162
+ file_path: the path to validate (should be repo-relative)
163
+ repo_root: the absolute repo root path
164
+
165
+ Raises:
166
+ ValueError: if path is absolute or escapes the repo root
167
+ """
168
+ # Check if path is absolute (reject).
169
+ if Path(file_path).is_absolute():
170
+ raise ValueError(f"file path must be relative, got absolute: {file_path}")
171
+
172
+ # Join with repo root and resolve to get the absolute path.
173
+ repo_root_path = Path(repo_root).resolve()
174
+ full_path = (repo_root_path / file_path).resolve()
175
+
176
+ # Verify the resolved path is still inside the repo root (reject traversal).
177
+ try:
178
+ # This will raise ValueError if full_path is not relative to repo_root_path
179
+ full_path.relative_to(repo_root_path)
180
+ except ValueError:
181
+ raise ValueError(f"file path escapes repo root: {file_path} (resolved to {full_path}, outside {repo_root_path})")
182
+
183
+
184
+ def _safe_slug(slug: str) -> str:
185
+ """Sanitize a slug to prevent path traversal attacks and enforce filesystem limits.
186
+
187
+ Whitelists [A-Za-z0-9_-]+ and rejects or normalizes everything else.
188
+ This prevents '../../../etc/x' style escape attempts when slug is used
189
+ in path joins.
190
+
191
+ LENGTH BOUND:
192
+ The returned slug is guaranteed to produce a journal filename (slug + '.json')
193
+ that fits within the 255-byte filesystem limit (stricter than MAX_PATH on
194
+ Windows). The normalized slug is truncated to ~200 characters, leaving room
195
+ for a '-' separator + 8-char hash suffix + '.json' extension.
196
+
197
+ When truncation occurs (slug > 200 chars after normalization), a stable
198
+ hash suffix is always appended to preserve uniqueness.
199
+
200
+ COLLISION PREVENTION:
201
+ If normalization changed the string (removed characters), appends a stable
202
+ suffix derived from a hash of the raw slug to prevent collisions when two
203
+ different raw slugs normalize to the same value.
204
+
205
+ Args:
206
+ slug: the slug to sanitize
207
+
208
+ Returns:
209
+ str: sanitized slug with only alphanumeric, underscore, hyphen,
210
+ optionally truncated and with a hash suffix if truncation or
211
+ normalization occurred
212
+
213
+ Raises:
214
+ ValueError: if slug is empty or contains only invalid characters
215
+ """
216
+ MAX_NORMALIZED_LEN = 200 # Leaves room for '-' + 8-char hash + '.json' (< 255)
217
+
218
+ if not slug:
219
+ raise ValueError("slug cannot be empty")
220
+
221
+ # Keep only alphanumeric, underscore, and hyphen
222
+ sanitized = re.sub(r'[^A-Za-z0-9_-]', '', slug)
223
+
224
+ if not sanitized:
225
+ raise ValueError(f"slug contains no valid characters: {slug}")
226
+
227
+ # Track if we need to append a hash suffix
228
+ needs_suffix = sanitized != slug # Normalization changed the string
229
+
230
+ # Truncate to MAX_NORMALIZED_LEN if necessary; mark for hash suffix
231
+ if len(sanitized) > MAX_NORMALIZED_LEN:
232
+ sanitized = sanitized[:MAX_NORMALIZED_LEN]
233
+ needs_suffix = True # Always append hash when truncated for uniqueness
234
+
235
+ # If normalization or truncation changed the string, append a stable suffix
236
+ if needs_suffix:
237
+ raw_hash = hashlib.sha1(slug.encode()).hexdigest()[:8]
238
+ sanitized = f"{sanitized}-{raw_hash}"
239
+
240
+ return sanitized
241
+
242
+
243
+ def _journal_key_for_item(item: Dict[str, Any]) -> str:
244
+ """Generate a collision-free journal key for an item, including repo context.
245
+
246
+ For items with a `repo` field, the key is:
247
+ safe-slug(repo-basename) + '--' + safe-slug(item-slug)
248
+ For items without a repo field, the key is:
249
+ safe-slug(item-slug)
250
+
251
+ This ensures same-slug items across repos don't collide.
252
+
253
+ Args:
254
+ item: dict with slug and optional repo field
255
+
256
+ Returns:
257
+ str: sanitized journal key
258
+ """
259
+ slug = item.get("slug", "unknown")
260
+ repo = item.get("repo")
261
+
262
+ if repo:
263
+ try:
264
+ # Get the basename of the repo (last component of the path)
265
+ repo_basename = Path(repo).resolve().name
266
+ repo_key = _safe_slug(repo_basename)
267
+ except Exception:
268
+ # Fallback: use the full repo path hashed
269
+ repo_key = _safe_slug(Path(repo).name or "repo")
270
+ item_key = _safe_slug(slug)
271
+ return f"{repo_key}--{item_key}"
272
+ else:
273
+ return _safe_slug(slug)
274
+
275
+
276
+ # ========================================================================
277
+ # Wave Recovery: Journal and Resume Support
278
+ # ========================================================================
279
+
280
+ def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str, Any], repo: str = None) -> None:
281
+ """Write a journal entry for an item's progress.
282
+
283
+ Args:
284
+ state_dir: directory path for state files
285
+ slug: item slug (identifier)
286
+ phase: phase name (e.g., "verified", "failed", "dispatched")
287
+ data: dict with outcome data (verified, testExit, repairs, etc.)
288
+ repo: optional repo path for repo-aware journal keying
289
+
290
+ Journal is stored as: state_dir/journal/<journal-key>.json with timestamp.
291
+ Journal key includes repo context if provided, to prevent collisions.
292
+ """
293
+ state_path = Path(state_dir)
294
+ journal_dir = state_path / "journal"
295
+ journal_dir.mkdir(parents=True, exist_ok=True)
296
+
297
+ # Generate repo-aware journal key.
298
+ try:
299
+ item_stub = {"slug": slug}
300
+ if repo:
301
+ item_stub["repo"] = repo
302
+ journal_key = _journal_key_for_item(item_stub)
303
+ except ValueError:
304
+ # Fail-closed: if key generation fails, skip journaling.
305
+ return
306
+
307
+ journal_file = journal_dir / f"{journal_key}.json"
308
+ entry = {
309
+ "slug": slug,
310
+ "repo": repo,
311
+ "phase": phase,
312
+ "timestamp": time.time(),
313
+ **data,
314
+ }
315
+
316
+ try:
317
+ journal_file.write_text(json.dumps(entry, default=str) + "\n")
318
+ except Exception:
319
+ # Fail-closed: if journal write fails, continue without journaling.
320
+ pass
321
+
322
+
323
+ def _load_journal_state(state_dir: str) -> Dict[str, Dict[str, Any]]:
324
+ """Load journal state from state_dir.
325
+
326
+ Reads all JSON files from state_dir/journal/ and returns a dict
327
+ mapping journal_key (repo--slug or just slug) -> journal_entry.
328
+
329
+ The entry is looked up by (repo, slug) tuple for resume matching.
330
+
331
+ Returns:
332
+ dict mapping journal_key -> {phase, verified, testExit, repo, ...}
333
+ Returns empty dict if journal dir doesn't exist.
334
+ """
335
+ state_path = Path(state_dir)
336
+ journal_dir = state_path / "journal"
337
+
338
+ if not journal_dir.exists():
339
+ return {}
340
+
341
+ journal_state = {}
342
+ try:
343
+ # Only read files that match the safe slug pattern to avoid traversal attacks.
344
+ for journal_file in journal_dir.glob("[A-Za-z0-9_-]*.json"):
345
+ try:
346
+ entry = json.loads(journal_file.read_text())
347
+ slug = entry.get("slug")
348
+ repo = entry.get("repo")
349
+ if slug:
350
+ # Use (repo, slug) as a composite key for lookup.
351
+ # If repo is None, key is just slug.
352
+ key = (repo, slug)
353
+ journal_state[key] = entry
354
+ except Exception:
355
+ # Skip malformed entries.
356
+ pass
357
+ except Exception:
358
+ # Fail-closed: if reading fails, return empty state.
359
+ pass
360
+
361
+ return journal_state
362
+
363
+
364
+ def _should_skip_from_journal(journal_entry: Dict[str, Any]) -> bool:
365
+ """Determine if an item should be skipped based on journal entry.
366
+
367
+ Skip only if verified=True (even then, trust-but-verify will re-run the test).
368
+ Re-run if verified=False or not present.
369
+
370
+ Args:
371
+ journal_entry: dict with verified, testExit, etc.
372
+
373
+ Returns:
374
+ bool: True if item should be skipped from build (only trust-verify),
375
+ False if item should be re-built.
376
+ """
377
+ return journal_entry.get("verified", False) is True
378
+
379
+
380
+ def _release_stale_leases(state_dir: str, journal_state: Dict[str, Dict[str, Any]]) -> None:
381
+ """Release stale leases from dead instances.
382
+
383
+ Scans journal for old instance_ids and releases their coordination leases
384
+ so resume can re-claim resources. Fail-closed: any release error is ignored.
385
+
386
+ Args:
387
+ state_dir: directory path for state files
388
+ journal_state: dict of journal entries by slug
389
+ """
390
+ if coordination is None:
391
+ return
392
+
393
+ try:
394
+ STATE_STORE_DIR = REPO / "state_store"
395
+ if str(STATE_STORE_DIR) not in sys.path:
396
+ sys.path.insert(0, str(STATE_STORE_DIR))
397
+ from state_store import store
398
+
399
+ db_path = Path(state_dir) / "state.db"
400
+ if not db_path.exists():
401
+ return
402
+
403
+ event_store = store.EventStore(str(db_path))
404
+
405
+ for slug, entry in journal_state.items():
406
+ old_instance_id = entry.get("instance_id")
407
+ if old_instance_id:
408
+ try:
409
+ coordination.release(event_store, resource=slug, instance_id=old_instance_id)
410
+ except Exception:
411
+ # Ignore release errors; fail-closed.
412
+ pass
413
+ except Exception:
414
+ # Fail-closed: if coordination is unavailable, continue without release.
415
+ pass
416
+
417
+
418
+ def run_wave(
419
+ driver: AgentDriver,
420
+ manifest: Dict[str, Any],
421
+ *,
422
+ state_dir: Optional[str] = None,
423
+ git: Optional[Dict[str, str]] = None,
424
+ resume_journal: bool = False,
425
+ ) -> Dict[str, Any]:
426
+ """Run a full multi-item wave through an AgentDriver backend.
427
+
428
+ Implements the complete wave algorithm: preflight ownership guard, parallel
429
+ build, bounded repair, optional adversarial review, and batched git ship.
430
+
431
+ Supports resumable waves: if resume_journal=True and state_dir exists,
432
+ skips items marked as verified in the journal and does trust-but-verify
433
+ re-running of their tests. Releases stale leases from dead instances.
434
+
435
+ Args:
436
+ driver: AgentDriver instance providing dispatch_worker, run_command, etc.
437
+ manifest: dict with:
438
+ - items: list of item dicts with {slug, ownsFiles, prompt, testCmd, workDir, ...}
439
+ - (optional) other manifest fields
440
+ state_dir: optional path to state directory for coordination claims and
441
+ cost_ceiling ledger. If None, these features are skipped.
442
+ git: optional dict with {expectTopLevel: str} for git operations. If None,
443
+ ship phase is skipped.
444
+ resume_journal: if True and state_dir exists, load journal and skip items
445
+ marked as verified (but re-run their tests for trust-but-verify).
446
+
447
+ Returns:
448
+ dict with structure:
449
+ {
450
+ "preflight_ok": bool,
451
+ "aborted": bool,
452
+ "abort_reason": str or None,
453
+ "built": [
454
+ {
455
+ "slug": str,
456
+ "dispatched": bool,
457
+ "verified": bool,
458
+ "testExit": int or None,
459
+ "repairs": int,
460
+ "error": str or None,
461
+ "filesWritten": [str],
462
+ "skipped_from_journal": bool (only if resume_journal=True),
463
+ },
464
+ ...
465
+ ],
466
+ "shipped": [str] or None (list of slugs, or None if git not configured),
467
+ "ceiling": dict or None (from cost_ceiling.check, or None if no ceiling),
468
+ "policy": dict (the resolved verification_policy),
469
+ "resume_stats": dict (only if resume_journal=True) with:
470
+ {
471
+ "skipped_from_journal": int,
472
+ "rebuilt": int,
473
+ }
474
+ }
475
+
476
+ Fail-safe invariants:
477
+ - Verified is True ONLY from run_command exit code 0.
478
+ - Any exception in an item's dispatch -> verified=False for that item.
479
+ - Cost ceiling: if check() says exceeded, abort immediately with no more dispatch.
480
+ - Disjoint ownership: any overlap -> abort with structured error, no dispatch.
481
+
482
+ Explicit-repo preflight (deprecate expectTopLevel default):
483
+ When git shipping is configured AND a manifest contains ANY item with an
484
+ explicit 'repo' field, items WITHOUT 'repo' are REJECTED at preflight
485
+ with a clear error. This prevents silent mismatches caused by mixed
486
+ manifests (some items use expectTopLevel default, others are explicit).
487
+
488
+ Pure-legacy manifests (NO item has 'repo') keep the expectTopLevel
489
+ default unchanged (backward compat). Fully-explicit manifests (ALL items
490
+ have 'repo') work unchanged. Mixed manifests are fail-closed.
491
+
492
+ Error: abort_reason="mixed_repo_manifest", with items_with_repo and
493
+ items_without_repo counts in the result.
494
+ """
495
+ result = {
496
+ "preflight_ok": False,
497
+ "aborted": False,
498
+ "abort_reason": None,
499
+ "built": [],
500
+ "shipped": None,
501
+ "ceiling": None,
502
+ "policy": None,
503
+ "resume_stats": None,
504
+ }
505
+
506
+ # Extract items from manifest.
507
+ items = manifest.get("items", [])
508
+
509
+ # ========================================================================
510
+ # PHASE 0 (optional): Resume - Load journal state and release stale leases
511
+ # ========================================================================
512
+ journal_state = {}
513
+ resume_stats = {"skipped_from_journal": 0, "rebuilt": 0}
514
+ resume_stats_lock = threading.Lock() # Protect resume_stats from concurrent access
515
+ if resume_journal and state_dir:
516
+ journal_state = _load_journal_state(state_dir)
517
+ _release_stale_leases(state_dir, journal_state)
518
+ if journal_state:
519
+ result["resume_stats"] = resume_stats
520
+
521
+ # ========================================================================
522
+ # PHASE 1: Preflight ownership guard (with per-repo validation)
523
+ # ========================================================================
524
+
525
+ # IMPORTANT: When git is configured (ship phase enabled), all items MUST have an
526
+ # absolute resolved `repo` field after preflight. This ensures manifests behave
527
+ # identically regardless of process cwd.
528
+ #
529
+ # Default for items without explicit `repo`:
530
+ # - If git config has expectTopLevel, use that (legacy behavior anchor)
531
+ # - If git config NOT present: no repo validation needed (non-ship-phase wave)
532
+ # - If git config present but NO expectTopLevel: error (can't default repo for shipping)
533
+
534
+ # Determine the default repo from git config (legacy anchor for byte-identical behavior).
535
+ # Only used when git config is present (i.e., shipping is enabled).
536
+ default_repo = None
537
+ if git is not None:
538
+ default_repo = git.get("expectTopLevel")
539
+
540
+ # EXPLICIT-REPO PREFLIGHT: Detect mixed manifests (some items with repo, some without).
541
+ # Contract:
542
+ # - Pure-legacy manifest (NO item has repo) → use expectTopLevel default (backward compat)
543
+ # - Fully-explicit manifest (ALL items have repo) → use explicit repos (unchanged)
544
+ # - Mixed manifest (SOME items have repo) → REJECT with clear error (fail-closed)
545
+ #
546
+ # This ensures:
547
+ # 1. Legacy manifests continue to work (no breaking change)
548
+ # 2. Explicit manifests work correctly (no confusion)
549
+ # 3. Mixed manifests are caught early, preventing subtle bugs
550
+ if git is not None:
551
+ # Count items with and without explicit repo field
552
+ items_with_repo = sum(1 for item in items if item.get("repo"))
553
+ items_without_repo = len(items) - items_with_repo
554
+
555
+ if items_with_repo > 0 and items_without_repo > 0:
556
+ # Mixed manifest: some items have repo, some don't
557
+ result["aborted"] = True
558
+ result["abort_reason"] = "mixed_repo_manifest"
559
+ result["error"] = (
560
+ "mixed manifest detected: some items have explicit 'repo' field, others don't. "
561
+ "Manifests must be fully explicit (all items have 'repo') or fully implicit (none have 'repo'). "
562
+ "To fix: either add 'repo' field to all items or remove it from all items (use expectTopLevel instead)."
563
+ )
564
+ result["items_with_repo"] = items_with_repo
565
+ result["items_without_repo"] = items_without_repo
566
+ return result
567
+
568
+ # Validate and resolve all repos; populate default for missing items (only if shipping).
569
+ repo_paths = set()
570
+
571
+ for item in items:
572
+ repo = item.get("repo")
573
+
574
+ if not repo:
575
+ # No explicit repo field.
576
+ if git is not None:
577
+ # Ship phase is configured; must have a default (repo field must be missing on ALL items, per mixed check above).
578
+ if default_repo:
579
+ repo = default_repo
580
+ else:
581
+ # Shipping enabled but can't default repo for this item.
582
+ result["aborted"] = True
583
+ result["abort_reason"] = "repo_field_missing_no_default"
584
+ result["error"] = "item requires 'repo' field when git shipping is configured (set expectTopLevel or add repo field)"
585
+ result["item_slug"] = item.get("slug", "unknown")
586
+ return result
587
+ else:
588
+ # No shipping phase; skip repo validation for this item.
589
+ # This allows backward-compatible non-shipping waves without repo fields.
590
+ continue
591
+
592
+ # Validate and resolve the repo path to absolute.
593
+ try:
594
+ repo_resolved = _validate_repo_path(repo)
595
+ repo_paths.add(repo_resolved)
596
+ # Update item with resolved path for later use (ensures byte-identical cwd).
597
+ item["repo"] = repo_resolved
598
+ except ValueError as e:
599
+ result["aborted"] = True
600
+ result["abort_reason"] = "invalid_repo_path"
601
+ result["invalid_repo"] = repo
602
+ result["error"] = str(e)
603
+ return result
604
+ # Future: also validate is_git_worktree, has_secret_scan_gate
605
+
606
+ # Per-repo ownership guard: track ownership within each repo separately.
607
+ # Structure: {repo: {normalized_file: slug}}
608
+ repo_owner_map = {} # repo -> (normalized file -> slug)
609
+ conflicts = []
610
+
611
+ for item in items:
612
+ slug = item.get("slug", "unknown")
613
+ owned_files = item.get("ownsFiles", [])
614
+ repo = item.get("repo", ".") # Default to cwd if no repo specified
615
+
616
+ # Normalize repo path
617
+ repo_normalized = str(Path(repo).resolve()).lower()
618
+
619
+ if repo_normalized not in repo_owner_map:
620
+ repo_owner_map[repo_normalized] = {}
621
+
622
+ owner_map = repo_owner_map[repo_normalized]
623
+
624
+ for f in owned_files:
625
+ # Platform-independent path normalization: handle separators and case uniformly.
626
+ # Replace all backslashes with forward slashes, normalize with posixpath,
627
+ # and convert to lowercase for case-insensitive comparison on all platforms.
628
+ normalized = posixpath.normpath(f.replace("\\", "/")).lower()
629
+ if normalized in owner_map:
630
+ conflicts.append(
631
+ {
632
+ "file": f,
633
+ "normalized": normalized,
634
+ "repo": repo,
635
+ "items": [owner_map[normalized], slug],
636
+ }
637
+ )
638
+ else:
639
+ owner_map[normalized] = slug
640
+
641
+ if conflicts:
642
+ result["aborted"] = True
643
+ result["abort_reason"] = "ownership_overlap"
644
+ result["conflicts"] = conflicts
645
+ return result
646
+
647
+ result["preflight_ok"] = True
648
+
649
+ # ========================================================================
650
+ # PHASE 2: Resolve verification policy ONCE
651
+ # ========================================================================
652
+ caps = driver.probe_capabilities()
653
+ policy = verification_policy(caps)
654
+ result["policy"] = policy
655
+
656
+ repair_cap = policy.get("repair_cap", 1)
657
+ spot_check_frac = policy.get("spot_check_frac", 0.0)
658
+ require_adversarial_review = policy.get("require_adversarial_review", False)
659
+
660
+ # ========================================================================
661
+ # PHASE 3: Cost-ceiling gate (before build)
662
+ # ========================================================================
663
+ if cost_ceiling is not None and state_dir is not None:
664
+ ceiling_result = cost_ceiling.check(
665
+ spent=driver.get_tokens_spent(),
666
+ trip=True,
667
+ state_dir=state_dir,
668
+ )
669
+ result["ceiling"] = ceiling_result
670
+
671
+ if ceiling_result.get("exceeded", False):
672
+ result["aborted"] = True
673
+ result["abort_reason"] = "cost_ceiling_exceeded"
674
+ return result
675
+
676
+ # ========================================================================
677
+ # PHASE 4: Build (PARALLEL with ThreadPoolExecutor)
678
+ # ========================================================================
679
+ # Prepare built items list and track for repair.
680
+ built_items = []
681
+ failed_items = [] # (index, item, result) tuples for repair
682
+
683
+ def build_item(item_index: int, item: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
684
+ """Build one item and return (index, result_dict)."""
685
+ slug = item.get("slug", f"item-{item_index}")
686
+ workdir = item.get("workDir", ".")
687
+ repo = item.get("repo")
688
+
689
+ # ====================================================================
690
+ # RESUME CHECK: If in journal and verified, skip dispatch and trust-verify
691
+ # ====================================================================
692
+ journal_key = (repo, slug)
693
+ journal_entry = journal_state.get(journal_key)
694
+ skipped_from_journal = False
695
+ if journal_entry and _should_skip_from_journal(journal_entry):
696
+ skipped_from_journal = True
697
+ with resume_stats_lock:
698
+ resume_stats["skipped_from_journal"] += 1
699
+
700
+ # Trust-but-verify: re-run the test for the journaled item
701
+ test_cmd = item.get("testCmd", "")
702
+ if test_cmd:
703
+ try:
704
+ test_result = driver.run_command(test_cmd, cwd=workdir)
705
+ if test_result.exit_code == 0:
706
+ # Test still passes; mark verified.
707
+ return (
708
+ item_index,
709
+ {
710
+ "slug": slug,
711
+ "dispatched": False,
712
+ "verified": True,
713
+ "testExit": 0,
714
+ "repairs": 0,
715
+ "error": None,
716
+ "filesWritten": [],
717
+ "skipped_from_journal": True,
718
+ "trust_verified": True,
719
+ },
720
+ )
721
+ else:
722
+ # Test failed on re-run; flip to False and mark for rebuild.
723
+ with resume_stats_lock:
724
+ resume_stats["rebuilt"] += 1
725
+ return (
726
+ item_index,
727
+ {
728
+ "slug": slug,
729
+ "dispatched": False,
730
+ "verified": False,
731
+ "testExit": test_result.exit_code,
732
+ "repairs": 0,
733
+ "error": "trust-verify test failed (re-run)",
734
+ "filesWritten": [],
735
+ "skipped_from_journal": True,
736
+ "trust_verified": False,
737
+ },
738
+ )
739
+ except Exception as exc:
740
+ # Test re-run failed; flip to False and mark for rebuild.
741
+ with resume_stats_lock:
742
+ resume_stats["rebuilt"] += 1
743
+ return (
744
+ item_index,
745
+ {
746
+ "slug": slug,
747
+ "dispatched": False,
748
+ "verified": False,
749
+ "testExit": None,
750
+ "repairs": 0,
751
+ "error": f"trust-verify exception: {exc}",
752
+ "filesWritten": [],
753
+ "skipped_from_journal": True,
754
+ "trust_verified": False,
755
+ },
756
+ )
757
+ else:
758
+ # No test command; just mark as skipped but not verified (safe).
759
+ return (
760
+ item_index,
761
+ {
762
+ "slug": slug,
763
+ "dispatched": False,
764
+ "verified": False,
765
+ "testExit": None,
766
+ "repairs": 0,
767
+ "error": "no test command for trust-verify",
768
+ "filesWritten": [],
769
+ "skipped_from_journal": True,
770
+ "trust_verified": False,
771
+ },
772
+ )
773
+
774
+ # ====================================================================
775
+ # NORMAL BUILD: Not in journal or was marked as failed
776
+ # ====================================================================
777
+ with resume_stats_lock:
778
+ resume_stats["rebuilt"] += 1
779
+
780
+ # Try to claim the item if state_dir is given (fail-closed on claim failure).
781
+ instance_id = f"wave-{uuid.uuid4()}"
782
+ claim_held = False
783
+ if coordination is not None and state_dir is not None:
784
+ try:
785
+ from state_store import store
786
+ db_path = Path(state_dir) / "state.db"
787
+ event_store = store.EventStore(str(db_path))
788
+ claim_held = coordination.try_claim(
789
+ event_store,
790
+ resource=slug,
791
+ instance_id=instance_id,
792
+ )
793
+ if not claim_held:
794
+ # Item is claimed by another instance; skip it.
795
+ return (
796
+ item_index,
797
+ {
798
+ "slug": slug,
799
+ "dispatched": False,
800
+ "verified": False,
801
+ "testExit": None,
802
+ "repairs": 0,
803
+ "error": "resource claimed by another instance",
804
+ "filesWritten": [],
805
+ },
806
+ )
807
+ except Exception:
808
+ # On exception, fail-closed: skip the item.
809
+ claim_held = False
810
+
811
+ try:
812
+ # Build the manifest item with policy.
813
+ manifest_item = build_manifest_item(driver, item)
814
+
815
+ # Dispatch the item.
816
+ dispatch_result = dispatch_item(driver, manifest_item, workdir=workdir)
817
+
818
+ item_result = {
819
+ "slug": slug,
820
+ "dispatched": dispatch_result.get("route") == "driver",
821
+ "verified": dispatch_result.get("verified", False),
822
+ "testExit": dispatch_result.get("testExit"),
823
+ "repairs": 0,
824
+ "error": dispatch_result.get("error"),
825
+ "filesWritten": dispatch_result.get("filesWritten", []),
826
+ "workerId": dispatch_result.get("workerId"),
827
+ }
828
+
829
+ # Write journal entry for this item's outcome.
830
+ if state_dir:
831
+ _write_journal_entry(state_dir, slug, "dispatched", {
832
+ "verified": item_result["verified"],
833
+ "testExit": item_result["testExit"],
834
+ "instance_id": instance_id,
835
+ }, repo=repo)
836
+
837
+ return (item_index, item_result)
838
+
839
+ except Exception as exc:
840
+ # Catch-all: any exception -> failed result, never a false green.
841
+ if state_dir:
842
+ _write_journal_entry(state_dir, slug, "failed", {
843
+ "verified": False,
844
+ "testExit": None,
845
+ "instance_id": instance_id,
846
+ "error": str(exc),
847
+ }, repo=repo)
848
+
849
+ return (
850
+ item_index,
851
+ {
852
+ "slug": slug,
853
+ "dispatched": False,
854
+ "verified": False,
855
+ "testExit": None,
856
+ "repairs": 0,
857
+ "error": f"build exception: {exc}",
858
+ "filesWritten": [],
859
+ },
860
+ )
861
+ finally:
862
+ # Release the claim if held.
863
+ if claim_held and coordination is not None and state_dir is not None:
864
+ try:
865
+ from state_store import store
866
+ db_path = Path(state_dir) / "state.db"
867
+ event_store = store.EventStore(str(db_path))
868
+ coordination.release(event_store, resource=slug, instance_id=instance_id)
869
+ except Exception:
870
+ pass
871
+
872
+ # Run build in parallel.
873
+ max_workers = min(8, len(items)) if items else 1
874
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
875
+ futures = [
876
+ executor.submit(build_item, i, item)
877
+ for i, item in enumerate(items)
878
+ ]
879
+ for future in concurrent.futures.as_completed(futures):
880
+ try:
881
+ item_index, item_result = future.result()
882
+ built_items.append((item_index, items[item_index], item_result))
883
+
884
+ # Track failed items for repair.
885
+ if not item_result["verified"]:
886
+ failed_items.append((item_index, items[item_index], item_result))
887
+ except Exception:
888
+ # Should not happen (build_item catches internally), but just in case.
889
+ pass
890
+
891
+ # Sort built_items by index to preserve order.
892
+ built_items.sort(key=lambda x: x[0])
893
+ result["built"] = [item_result for _, _, item_result in built_items]
894
+
895
+ # ========================================================================
896
+ # PHASE 5: Bounded repair
897
+ # ========================================================================
898
+ for repair_round in range(repair_cap):
899
+ if not failed_items:
900
+ break
901
+
902
+ # Cost-ceiling check before repair round.
903
+ if cost_ceiling is not None and state_dir is not None:
904
+ ceiling_result = cost_ceiling.check(
905
+ spent=driver.get_tokens_spent(),
906
+ trip=True,
907
+ state_dir=state_dir,
908
+ )
909
+ if ceiling_result.get("exceeded", False):
910
+ result["aborted"] = True
911
+ result["abort_reason"] = "cost_ceiling_exceeded_in_repair"
912
+ return result
913
+
914
+ # Repair each failed item.
915
+ next_failed = []
916
+ for item_index, item, item_result in failed_items:
917
+ slug = item.get("slug", f"item-{item_index}")
918
+ workdir = item.get("workDir", ".")
919
+ test_cmd = item.get("testCmd", "")
920
+
921
+ # Build repair prompt: append test output to original prompt.
922
+ original_prompt = item.get("prompt", "")
923
+ test_output = f"\n\nTest failed with exit code {item_result['testExit']}.\n"
924
+ if item_result.get("error"):
925
+ test_output += f"Error: {item_result['error']}\n"
926
+ repair_prompt = original_prompt + test_output
927
+
928
+ # Create a repair item.
929
+ repair_item = dict(item)
930
+ repair_item["prompt"] = repair_prompt
931
+
932
+ try:
933
+ # Build the manifest item.
934
+ manifest_item = build_manifest_item(driver, repair_item)
935
+
936
+ # Dispatch the repair.
937
+ dispatch_result = dispatch_item(driver, manifest_item, workdir=workdir)
938
+
939
+ # Update the item result.
940
+ item_result["verified"] = dispatch_result.get("verified", False)
941
+ item_result["testExit"] = dispatch_result.get("testExit")
942
+ item_result["error"] = dispatch_result.get("error")
943
+ item_result["filesWritten"] = dispatch_result.get("filesWritten", [])
944
+ item_result["repairs"] += 1
945
+
946
+ # Write journal entry for repair outcome.
947
+ if state_dir:
948
+ repo = item.get("repo")
949
+ _write_journal_entry(state_dir, slug, "repaired", {
950
+ "verified": item_result["verified"],
951
+ "testExit": item_result["testExit"],
952
+ "repairs": item_result["repairs"],
953
+ }, repo=repo)
954
+
955
+ # If still failed, mark for next round.
956
+ if not item_result["verified"]:
957
+ next_failed.append((item_index, item, item_result))
958
+
959
+ except Exception as exc:
960
+ item_result["error"] = f"repair exception: {exc}"
961
+ item_result["repairs"] += 1
962
+
963
+ # Write journal entry for repair failure.
964
+ if state_dir:
965
+ repo = item.get("repo")
966
+ _write_journal_entry(state_dir, slug, "repair_failed", {
967
+ "verified": False,
968
+ "testExit": None,
969
+ "repairs": item_result["repairs"],
970
+ "error": str(exc),
971
+ }, repo=repo)
972
+
973
+ next_failed.append((item_index, item, item_result))
974
+
975
+ # Update failed_items for next round.
976
+ failed_items = next_failed
977
+
978
+ # ========================================================================
979
+ # PHASE 5.5: Spot-check verified items (if spot_check_frac > 0)
980
+ # ========================================================================
981
+ if spot_check_frac > 0:
982
+ # Collect verified items and their original test commands.
983
+ verified_items_to_check = []
984
+ for item_index, (idx, original_item, item_result) in enumerate(
985
+ [(i, items[i], r) for i, r in enumerate(result["built"]) if r.get("verified", False)]
986
+ ):
987
+ verified_items_to_check.append((original_item, item_result))
988
+
989
+ # Determine how many to spot-check.
990
+ num_to_check = ceil(len(verified_items_to_check) * spot_check_frac)
991
+
992
+ # Deterministic sampling: check first N items by slug order.
993
+ # Sort by slug for determinism, then check the first num_to_check.
994
+ verified_items_to_check.sort(key=lambda x: x[0].get("slug", ""))
995
+ items_to_rerun = verified_items_to_check[:num_to_check]
996
+
997
+ # Re-run tests for sampled items.
998
+ for original_item, item_result in items_to_rerun:
999
+ test_cmd = original_item.get("testCmd", "")
1000
+ workdir = original_item.get("workDir", ".")
1001
+
1002
+ if test_cmd:
1003
+ try:
1004
+ rerun_result = driver.run_command(test_cmd, cwd=workdir)
1005
+ # If re-run does NOT exit 0, flip verified to False.
1006
+ if rerun_result.exit_code != 0:
1007
+ item_result["verified"] = False
1008
+ item_result["spot_check_failed"] = True
1009
+ except Exception:
1010
+ # On exception, flip verified to False.
1011
+ item_result["verified"] = False
1012
+ item_result["spot_check_failed"] = True
1013
+
1014
+ # ========================================================================
1015
+ # PHASE 6: Adversarial review (deferred, not yet enforced)
1016
+ # ========================================================================
1017
+ # Adversarial review is not yet implemented; mark all as deferred.
1018
+ # (TODO in a later increment: real adversarial review dispatch via driver)
1019
+ result["adversarial_review"] = "deferred"
1020
+ for item_result in result["built"]:
1021
+ item_result["adversarial_review"] = "deferred"
1022
+
1023
+ # ========================================================================
1024
+ # PHASE 7: Per-repo ship (git operations, if configured)
1025
+ # ========================================================================
1026
+ if git is not None:
1027
+ # Verify expectTopLevel guard: MUST be a non-empty string matching actual toplevel.
1028
+ expect_top_level = git.get("expectTopLevel")
1029
+ if not expect_top_level or not isinstance(expect_top_level, str):
1030
+ # Empty or missing expectTopLevel with git config is an error.
1031
+ result["aborted"] = True
1032
+ result["abort_reason"] = "git_toplevel_missing_or_empty"
1033
+ return result
1034
+
1035
+ # Only ship items that verified green.
1036
+ # Build a slug -> original_item mapping for lookup.
1037
+ slug_to_item = {item.get("slug"): (i, item) for i, item in enumerate(items)}
1038
+
1039
+ verified_items = []
1040
+ for item_result in result["built"]:
1041
+ if item_result.get("verified", False):
1042
+ slug = item_result.get("slug")
1043
+ if slug in slug_to_item:
1044
+ item_index, original_item = slug_to_item[slug]
1045
+ verified_items.append((item_index, original_item, item_result))
1046
+
1047
+ if verified_items:
1048
+ # Group verified items by their resolved repo.
1049
+ repo_to_items = {} # {repo_path: [(item_index, original_item, item_result), ...]}
1050
+ for item_index, original_item, item_result in verified_items:
1051
+ repo = original_item.get("repo", ".")
1052
+ # Resolve and validate repo path.
1053
+ try:
1054
+ repo_resolved = _validate_repo_path(repo)
1055
+ except ValueError:
1056
+ # Fail-closed: this repo is invalid, mark as error but continue.
1057
+ item_result["ship_error"] = f"invalid repo path: {repo}"
1058
+ continue
1059
+
1060
+ if repo_resolved not in repo_to_items:
1061
+ repo_to_items[repo_resolved] = []
1062
+ repo_to_items[repo_resolved].append((item_index, original_item, item_result))
1063
+
1064
+ # Ship each repo separately.
1065
+ shipped_items = []
1066
+ repo_ship_results = [] # {repo, committed, sha, files_count, error}
1067
+
1068
+ for repo_path, repo_items in repo_to_items.items():
1069
+ # Verify expectTopLevel guard PER REPO:
1070
+ # Each repo's toplevel must equal the global expectTopLevel OR the repo's own root.
1071
+ # First, verify the repo is actually a git repo with the right toplevel.
1072
+ toplevel_result = driver.run_command(
1073
+ "git rev-parse --show-toplevel",
1074
+ cwd=repo_path
1075
+ )
1076
+ if toplevel_result.exit_code != 0:
1077
+ # This repo's git is broken; abort THIS repo's ship but continue others.
1078
+ repo_ship_results.append({
1079
+ "repo": repo_path,
1080
+ "committed": False,
1081
+ "error": "git_toplevel_check_failed",
1082
+ "files_count": len(repo_items),
1083
+ })
1084
+ # Mark items from this repo as shipped_error.
1085
+ for _, _, item_result in repo_items:
1086
+ item_result["ship_error"] = "git_toplevel_check_failed"
1087
+ continue
1088
+
1089
+ toplevel = toplevel_result.stdout.strip()
1090
+ # Normalize paths for comparison (git may return with / on Windows)
1091
+ toplevel_normalized = str(Path(toplevel).resolve())
1092
+ repo_path_normalized = str(Path(repo_path).resolve())
1093
+
1094
+ # Per-repo guard: the repo's toplevel must match that repo's own root.
1095
+ # This ensures we're not operating on a subdirectory or symlink escaping.
1096
+ if toplevel_normalized != repo_path_normalized:
1097
+ # Top-level mismatch; abort THIS repo's ship but continue others.
1098
+ repo_ship_results.append({
1099
+ "repo": repo_path,
1100
+ "committed": False,
1101
+ "error": "git_toplevel_mismatch",
1102
+ "files_count": len(repo_items),
1103
+ "expected_repo_root": repo_path_normalized,
1104
+ "actual_toplevel": toplevel_normalized,
1105
+ })
1106
+ # Mark items from this repo as shipped_error.
1107
+ for _, _, item_result in repo_items:
1108
+ item_result["ship_error"] = "git_toplevel_mismatch"
1109
+ continue
1110
+
1111
+ # Collect files for this repo (repo-relative).
1112
+ files_to_add = []
1113
+ for _, _, item_result in repo_items:
1114
+ files_to_add.extend(item_result.get("filesWritten", []))
1115
+
1116
+ if files_to_add:
1117
+ # VALIDATION P2: Validate all filesWritten paths before git operations.
1118
+ # Ensure they are relative and don't escape the repo root.
1119
+ invalid_files = []
1120
+ for file_path in files_to_add:
1121
+ try:
1122
+ _validate_file_path(file_path, repo_path)
1123
+ except ValueError as e:
1124
+ invalid_files.append((file_path, str(e)))
1125
+
1126
+ if invalid_files:
1127
+ # Path validation failed; fail this item explicitly.
1128
+ repo_ship_results.append({
1129
+ "repo": repo_path,
1130
+ "committed": False,
1131
+ "error": "invalid_file_paths",
1132
+ "files_count": len(repo_items),
1133
+ "invalid_files": invalid_files,
1134
+ })
1135
+ # Mark items from this repo as shipped_error.
1136
+ for _, _, item_result in repo_items:
1137
+ item_result["ship_error"] = f"invalid file paths: {invalid_files}"
1138
+ continue
1139
+
1140
+ # Add files. Escape each filename to prevent shell injection.
1141
+ escaped_files = [_quote_arg(f) for f in files_to_add]
1142
+ add_cmd = "git add " + " ".join(escaped_files)
1143
+ add_result = driver.run_command(add_cmd, cwd=repo_path)
1144
+ if add_result.exit_code != 0:
1145
+ # GIT ADD FAILURE P1: git add may have partially succeeded,
1146
+ # leaving staged residue. Run git reset to clean the index.
1147
+ reset_result = driver.run_command("git reset", cwd=repo_path)
1148
+ unstage_ok = reset_result.exit_code == 0
1149
+
1150
+ repo_ship_results.append({
1151
+ "repo": repo_path,
1152
+ "committed": False,
1153
+ "error": "git_add_failed",
1154
+ # Truncated stderr/stdout for diagnostics (same as commit failure path)
1155
+ "error_detail": ((add_result.stderr or "") + " | " + (add_result.stdout or ""))[:300],
1156
+ "files_count": len(repo_items),
1157
+ "files_unstaged": unstage_ok,
1158
+ "unstage_error": None if unstage_ok else reset_result.stderr,
1159
+ })
1160
+ # Mark items from this repo as shipped_error.
1161
+ for _, _, item_result in repo_items:
1162
+ item_result["ship_error"] = "git_add_failed"
1163
+ continue
1164
+
1165
+ # Commit. Escape the message to prevent shell injection.
1166
+ commit_msg = f"Wave: {len(repo_items)} items verified"
1167
+ commit_cmd = f"git commit -m {_quote_arg(commit_msg)}"
1168
+ commit_result = driver.run_command(commit_cmd, cwd=repo_path)
1169
+ if commit_result.exit_code != 0:
1170
+ # UNSTAGE P3: Commit failed; run git reset to unstage the files.
1171
+ # This prevents staged-files residue on partial failure.
1172
+ reset_result = driver.run_command("git reset", cwd=repo_path)
1173
+ unstage_ok = reset_result.exit_code == 0
1174
+
1175
+ repo_ship_results.append({
1176
+ "repo": repo_path,
1177
+ "committed": False,
1178
+ "error": "git_commit_failed",
1179
+ # Truncated stderr/stdout: a bare label is undiagnosable
1180
+ # from the Report (identity, hooks, lock contention all
1181
+ # land here with different remedies).
1182
+ "error_detail": ((commit_result.stderr or "") + " | " + (commit_result.stdout or ""))[:300],
1183
+ "files_count": len(repo_items),
1184
+ "files_unstaged": unstage_ok,
1185
+ "unstage_error": None if unstage_ok else reset_result.stderr,
1186
+ })
1187
+ # Mark items from this repo as shipped_error.
1188
+ for _, _, item_result in repo_items:
1189
+ item_result["ship_error"] = "git_commit_failed"
1190
+ continue
1191
+
1192
+ # Get the commit SHA.
1193
+ sha_result = driver.run_command("git rev-parse HEAD", cwd=repo_path)
1194
+ commit_sha = sha_result.stdout.strip() if sha_result.exit_code == 0 else None
1195
+
1196
+ # Push.
1197
+ push_result = driver.run_command("git push", cwd=repo_path)
1198
+ if push_result.exit_code != 0:
1199
+ # Push failed; abort THIS repo's push but continue others.
1200
+ repo_ship_results.append({
1201
+ "repo": repo_path,
1202
+ "committed": True,
1203
+ "sha": commit_sha,
1204
+ "error": "git_push_failed",
1205
+ "files_count": len(repo_items),
1206
+ })
1207
+ # Mark items from this repo as shipped (commit succeeded even if push failed).
1208
+ for _, _, item_result in repo_items:
1209
+ item_result["ship_warning"] = "git_push_failed"
1210
+ shipped_items.append(item_result["slug"])
1211
+ continue
1212
+
1213
+ # Success: record this repo's ship.
1214
+ repo_ship_results.append({
1215
+ "repo": repo_path,
1216
+ "committed": True,
1217
+ "sha": commit_sha,
1218
+ "files_count": len(repo_items),
1219
+ })
1220
+ # Mark items from this repo as shipped.
1221
+ for _, _, item_result in repo_items:
1222
+ shipped_items.append(item_result["slug"])
1223
+
1224
+ # Record shipped items and per-repo results.
1225
+ if shipped_items:
1226
+ result["shipped"] = shipped_items
1227
+ if repo_ship_results:
1228
+ result["shipped_repos"] = repo_ship_results
1229
+
1230
+ return result
1231
+
1232
+
1233
+ def result_to_report(wave_result: Dict[str, Any]) -> Dict[str, Any]:
1234
+ """Convert run_wave result dict to fleet_ledger Report JSON format.
1235
+
1236
+ The Report JSON is compatible with `fleet_ledger.py append-wave` and contains:
1237
+ - tokens: {buildOut, verifyOut, repairOut, totalOut}
1238
+ - integration: {green: bool, ...}
1239
+ - repairsUsed: int
1240
+ - built: [item results]
1241
+ - preflight_ok: bool
1242
+ - aborted: bool
1243
+
1244
+ Args:
1245
+ wave_result: dict returned from run_wave()
1246
+
1247
+ Returns:
1248
+ dict in fleet_ledger Report format
1249
+ """
1250
+ built_items = wave_result.get("built", [])
1251
+ repairs_used = sum(item.get("repairs", 0) for item in built_items)
1252
+
1253
+ # Determine if wave was fully green (all items verified and not aborted).
1254
+ green = not wave_result.get("aborted", False) and all(
1255
+ item.get("verified", False) for item in built_items
1256
+ )
1257
+
1258
+ report = {
1259
+ "tokens": {
1260
+ "buildOut": 100, # Placeholder; driver should track real tokens
1261
+ "verifyOut": 0,
1262
+ "repairOut": repairs_used * 50 if repairs_used > 0 else 0,
1263
+ "totalOut": 100 + repairs_used * 50,
1264
+ },
1265
+ "integration": {
1266
+ "green": green,
1267
+ },
1268
+ "repairsUsed": repairs_used,
1269
+ "built": built_items,
1270
+ "preflight_ok": wave_result.get("preflight_ok", False),
1271
+ "aborted": wave_result.get("aborted", False),
1272
+ }
1273
+
1274
+ return report
1275
+
1276
+
1277
+ def main():
1278
+ """CLI entrypoint for one-turn wave mode.
1279
+
1280
+ Usage:
1281
+ python -m driver.wave_loop --manifest <path> [--one-turn] [--state-dir <path>] [--output <path>]
1282
+
1283
+ The --one-turn flag enables the complete wave sequence in one invocation.
1284
+ Output is JSON (either to stdout or --output file).
1285
+ """
1286
+ import argparse
1287
+
1288
+ parser = argparse.ArgumentParser(
1289
+ description="One-turn wave mode: run a complete wave (preflight - build - verify - repair - report)",
1290
+ formatter_class=argparse.RawDescriptionHelpFormatter,
1291
+ epilog="""
1292
+ Examples:
1293
+ python driver/wave_loop.py --manifest wave.json --one-turn
1294
+ python driver/wave_loop.py --manifest wave.json --one-turn --output report.json
1295
+ python driver/wave_loop.py --manifest wave.json --one-turn --state-dir ./state
1296
+ """,
1297
+ )
1298
+
1299
+ parser.add_argument(
1300
+ "--manifest",
1301
+ required=True,
1302
+ type=str,
1303
+ help="Path to wave manifest JSON file (required)",
1304
+ )
1305
+ parser.add_argument(
1306
+ "--one-turn",
1307
+ action="store_true",
1308
+ help="Run the complete wave in one turn (preflight - build - verify - repair - report)",
1309
+ )
1310
+ parser.add_argument(
1311
+ "--state-dir",
1312
+ type=str,
1313
+ default=None,
1314
+ help="Path to state directory for coordination/cost tracking (optional)",
1315
+ )
1316
+ parser.add_argument(
1317
+ "--output",
1318
+ type=str,
1319
+ default=None,
1320
+ help="Output file for Report JSON (default: stdout)",
1321
+ )
1322
+ parser.add_argument(
1323
+ "--git",
1324
+ action="store_true",
1325
+ help="Enable git operations (stage, commit, push verified items)",
1326
+ )
1327
+
1328
+ args = parser.parse_args()
1329
+
1330
+ # Load manifest from JSON file.
1331
+ try:
1332
+ manifest_path = Path(args.manifest)
1333
+ if not manifest_path.exists():
1334
+ print(f"Error: manifest file not found: {args.manifest}", file=sys.stderr)
1335
+ return 1
1336
+
1337
+ with open(manifest_path) as f:
1338
+ manifest = json.load(f)
1339
+ except json.JSONDecodeError as e:
1340
+ print(f"Error: invalid JSON in manifest file: {e}", file=sys.stderr)
1341
+ return 1
1342
+ except Exception as e:
1343
+ print(f"Error: failed to load manifest: {e}", file=sys.stderr)
1344
+ return 1
1345
+
1346
+ # For now, use the Claude Code reference driver.
1347
+ # In the future, this should be configurable via backend_config.
1348
+ try:
1349
+ from claude_code_driver import ClaudeCodeDriver
1350
+ driver = ClaudeCodeDriver()
1351
+ except ImportError:
1352
+ print(
1353
+ "Error: could not import ClaudeCodeDriver. "
1354
+ "Ensure driver/ is on the Python path.",
1355
+ file=sys.stderr,
1356
+ )
1357
+ return 1
1358
+
1359
+ # Prepare git config if --git flag is used.
1360
+ git_config = None
1361
+ if args.git:
1362
+ # Get the current top-level directory as a guard.
1363
+ toplevel_result = driver.run_command("git rev-parse --show-toplevel")
1364
+ if toplevel_result.exit_code != 0:
1365
+ print("Error: could not determine git top-level directory", file=sys.stderr)
1366
+ return 1
1367
+ toplevel = toplevel_result.stdout.strip()
1368
+ git_config = {"expectTopLevel": toplevel}
1369
+
1370
+ # Run the wave.
1371
+ try:
1372
+ result = run_wave(
1373
+ driver,
1374
+ manifest,
1375
+ state_dir=args.state_dir,
1376
+ git=git_config,
1377
+ )
1378
+ except Exception as e:
1379
+ print(f"Error: wave execution failed: {e}", file=sys.stderr)
1380
+ return 1
1381
+
1382
+ # Convert result to Report JSON format.
1383
+ report = result_to_report(result)
1384
+
1385
+ # Output Report JSON.
1386
+ report_json = json.dumps(report, indent=2)
1387
+
1388
+ if args.output:
1389
+ try:
1390
+ with open(args.output, "w") as f:
1391
+ f.write(report_json)
1392
+ print(f"Report written to {args.output}", file=sys.stderr)
1393
+ except Exception as e:
1394
+ print(f"Error: failed to write report file: {e}", file=sys.stderr)
1395
+ return 1
1396
+ else:
1397
+ print(report_json)
1398
+
1399
+ # Return exit code based on wave status.
1400
+ # Exit 0 if wave completed (aborted or not), exit 1 if preflight failed.
1401
+ if not result.get("preflight_ok"):
1402
+ return 1
1403
+
1404
+ return 0
1405
+
1406
+
1407
+ if __name__ == "__main__":
1408
+ sys.exit(main())