@matt82198/aesop 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -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/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,1041 @@
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
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. Batched ship: if git config given, add/commit/push the verified items
20
+
21
+ HONESTY GUARANTEE:
22
+ - Verified = True ONLY if the item's test passed (exit code 0 from run_command).
23
+ - Any exception -> item.verified = False, never a false green.
24
+ - Ownership is enforced at the driver level (dispatch_worker rejects out-of-scope).
25
+ - Adversarial review is NOT yet enforced; marked as 'deferred' (TODO in a later increment).
26
+
27
+ FAIL-SAFE:
28
+ - Cost-ceiling check: if exceeded, ABORT the wave immediately (return early).
29
+ - Disjoint ownership: any overlap -> ABORT with structured error, no dispatch.
30
+ - Repair cap bounded: never infinite retry loop.
31
+
32
+ stdlib-only, ASCII-only, Windows + Linux safe.
33
+ """
34
+
35
+ import concurrent.futures
36
+ import hashlib
37
+ import json
38
+ import os
39
+ import posixpath
40
+ import re
41
+ import shlex
42
+ import sys
43
+ import threading
44
+ import time
45
+ import uuid
46
+ from math import ceil
47
+ from pathlib import Path
48
+ from typing import Optional, Dict, Any, List, Tuple
49
+
50
+ # Add driver/ to path for imports.
51
+ REPO = Path(__file__).resolve().parent.parent
52
+ DRIVER_DIR = REPO / "driver"
53
+ if str(DRIVER_DIR) not in sys.path:
54
+ sys.path.insert(0, str(DRIVER_DIR))
55
+
56
+ from agent_driver import AgentDriver
57
+ from wave_bridge import build_manifest_item, dispatch_item
58
+ from verification_policy import verification_policy
59
+
60
+ # Try to import cost_ceiling and coordination (optional, for safety gates).
61
+ try:
62
+ import sys
63
+ TOOLS_DIR = REPO / "tools"
64
+ if str(TOOLS_DIR) not in sys.path:
65
+ sys.path.insert(0, str(TOOLS_DIR))
66
+ import cost_ceiling
67
+ except ImportError:
68
+ cost_ceiling = None
69
+
70
+ try:
71
+ STATE_STORE_DIR = REPO / "state_store"
72
+ if str(STATE_STORE_DIR) not in sys.path:
73
+ sys.path.insert(0, str(STATE_STORE_DIR))
74
+ import coordination
75
+ except ImportError:
76
+ coordination = None
77
+
78
+
79
+ # ========================================================================
80
+ # Sanitization and Security
81
+ # ========================================================================
82
+
83
+ def _quote_arg(s: str) -> str:
84
+ """Quote an argument for safe shell execution across Windows and POSIX.
85
+
86
+ On Windows (cmd.exe), single quotes don't quote; shlex.quote (POSIX-only)
87
+ is unsafe. This function uses subprocess.list2cmdline semantics for Windows
88
+ and shlex.quote for POSIX systems.
89
+
90
+ The durable fix is to refactor run_command to accept a list of arguments
91
+ instead of shell=True strings (deferred).
92
+
93
+ Args:
94
+ s: the string to quote for shell execution
95
+
96
+ Returns:
97
+ str: properly quoted argument safe for shell execution on this OS
98
+ """
99
+ if os.name == 'nt':
100
+ # Windows (cmd.exe): use subprocess.list2cmdline semantics.
101
+ # Double quotes are the safe quoting mechanism; embed quotes are escaped
102
+ # with backslash, and backslashes before quotes are escaped.
103
+ # For safety, we wrap in double quotes and escape embedded quotes/backslashes.
104
+ if not s:
105
+ return '""'
106
+ # Escape backslashes before quotes, then escape quotes
107
+ escaped = s.replace('\\', '\\\\').replace('"', '\\"')
108
+ return f'"{escaped}"'
109
+ else:
110
+ # POSIX: shlex.quote handles all cases safely
111
+ return shlex.quote(s)
112
+
113
+
114
+ def _safe_slug(slug: str) -> str:
115
+ """Sanitize a slug to prevent path traversal attacks and enforce filesystem limits.
116
+
117
+ Whitelists [A-Za-z0-9_-]+ and rejects or normalizes everything else.
118
+ This prevents '../../../etc/x' style escape attempts when slug is used
119
+ in path joins.
120
+
121
+ LENGTH BOUND:
122
+ The returned slug is guaranteed to produce a journal filename (slug + '.json')
123
+ that fits within the 255-byte filesystem limit (stricter than MAX_PATH on
124
+ Windows). The normalized slug is truncated to ~200 characters, leaving room
125
+ for a '-' separator + 8-char hash suffix + '.json' extension.
126
+
127
+ When truncation occurs (slug > 200 chars after normalization), a stable
128
+ hash suffix is always appended to preserve uniqueness.
129
+
130
+ COLLISION PREVENTION:
131
+ If normalization changed the string (removed characters), appends a stable
132
+ suffix derived from a hash of the raw slug to prevent collisions when two
133
+ different raw slugs normalize to the same value.
134
+
135
+ Args:
136
+ slug: the slug to sanitize
137
+
138
+ Returns:
139
+ str: sanitized slug with only alphanumeric, underscore, hyphen,
140
+ optionally truncated and with a hash suffix if truncation or
141
+ normalization occurred
142
+
143
+ Raises:
144
+ ValueError: if slug is empty or contains only invalid characters
145
+ """
146
+ MAX_NORMALIZED_LEN = 200 # Leaves room for '-' + 8-char hash + '.json' (< 255)
147
+
148
+ if not slug:
149
+ raise ValueError("slug cannot be empty")
150
+
151
+ # Keep only alphanumeric, underscore, and hyphen
152
+ sanitized = re.sub(r'[^A-Za-z0-9_-]', '', slug)
153
+
154
+ if not sanitized:
155
+ raise ValueError(f"slug contains no valid characters: {slug}")
156
+
157
+ # Track if we need to append a hash suffix
158
+ needs_suffix = sanitized != slug # Normalization changed the string
159
+
160
+ # Truncate to MAX_NORMALIZED_LEN if necessary; mark for hash suffix
161
+ if len(sanitized) > MAX_NORMALIZED_LEN:
162
+ sanitized = sanitized[:MAX_NORMALIZED_LEN]
163
+ needs_suffix = True # Always append hash when truncated for uniqueness
164
+
165
+ # If normalization or truncation changed the string, append a stable suffix
166
+ if needs_suffix:
167
+ raw_hash = hashlib.sha1(slug.encode()).hexdigest()[:8]
168
+ sanitized = f"{sanitized}-{raw_hash}"
169
+
170
+ return sanitized
171
+
172
+
173
+ # ========================================================================
174
+ # Wave Recovery: Journal and Resume Support
175
+ # ========================================================================
176
+
177
+ def _write_journal_entry(state_dir: str, slug: str, phase: str, data: Dict[str, Any]) -> None:
178
+ """Write a journal entry for an item's progress.
179
+
180
+ Args:
181
+ state_dir: directory path for state files
182
+ slug: item slug (identifier)
183
+ phase: phase name (e.g., "verified", "failed", "dispatched")
184
+ data: dict with outcome data (verified, testExit, repairs, etc.)
185
+
186
+ Journal is stored as: state_dir/journal/<slug>.json with timestamp.
187
+ Slug is sanitized to prevent path traversal attacks.
188
+ """
189
+ state_path = Path(state_dir)
190
+ journal_dir = state_path / "journal"
191
+ journal_dir.mkdir(parents=True, exist_ok=True)
192
+
193
+ # Sanitize slug to prevent path traversal.
194
+ try:
195
+ safe_slug = _safe_slug(slug)
196
+ except ValueError:
197
+ # Fail-closed: if slug is invalid, skip journaling.
198
+ return
199
+
200
+ journal_file = journal_dir / f"{safe_slug}.json"
201
+ entry = {
202
+ "slug": slug,
203
+ "phase": phase,
204
+ "timestamp": time.time(),
205
+ **data,
206
+ }
207
+
208
+ try:
209
+ journal_file.write_text(json.dumps(entry, default=str) + "\n")
210
+ except Exception:
211
+ # Fail-closed: if journal write fails, continue without journaling.
212
+ pass
213
+
214
+
215
+ def _load_journal_state(state_dir: str) -> Dict[str, Dict[str, Any]]:
216
+ """Load journal state from state_dir.
217
+
218
+ Reads all JSON files from state_dir/journal/ and returns a dict
219
+ mapping slug -> journal_entry.
220
+
221
+ Returns:
222
+ dict mapping slug -> {phase, verified, testExit, ...}
223
+ Returns empty dict if journal dir doesn't exist.
224
+ """
225
+ state_path = Path(state_dir)
226
+ journal_dir = state_path / "journal"
227
+
228
+ if not journal_dir.exists():
229
+ return {}
230
+
231
+ journal_state = {}
232
+ try:
233
+ # Only read files that match the safe slug pattern to avoid traversal attacks.
234
+ for journal_file in journal_dir.glob("[A-Za-z0-9_-]*.json"):
235
+ try:
236
+ entry = json.loads(journal_file.read_text())
237
+ slug = entry.get("slug")
238
+ if slug:
239
+ journal_state[slug] = entry
240
+ except Exception:
241
+ # Skip malformed entries.
242
+ pass
243
+ except Exception:
244
+ # Fail-closed: if reading fails, return empty state.
245
+ pass
246
+
247
+ return journal_state
248
+
249
+
250
+ def _should_skip_from_journal(journal_entry: Dict[str, Any]) -> bool:
251
+ """Determine if an item should be skipped based on journal entry.
252
+
253
+ Skip only if verified=True (even then, trust-but-verify will re-run the test).
254
+ Re-run if verified=False or not present.
255
+
256
+ Args:
257
+ journal_entry: dict with verified, testExit, etc.
258
+
259
+ Returns:
260
+ bool: True if item should be skipped from build (only trust-verify),
261
+ False if item should be re-built.
262
+ """
263
+ return journal_entry.get("verified", False) is True
264
+
265
+
266
+ def _release_stale_leases(state_dir: str, journal_state: Dict[str, Dict[str, Any]]) -> None:
267
+ """Release stale leases from dead instances.
268
+
269
+ Scans journal for old instance_ids and releases their coordination leases
270
+ so resume can re-claim resources. Fail-closed: any release error is ignored.
271
+
272
+ Args:
273
+ state_dir: directory path for state files
274
+ journal_state: dict of journal entries by slug
275
+ """
276
+ if coordination is None:
277
+ return
278
+
279
+ try:
280
+ STATE_STORE_DIR = REPO / "state_store"
281
+ if str(STATE_STORE_DIR) not in sys.path:
282
+ sys.path.insert(0, str(STATE_STORE_DIR))
283
+ from state_store import store
284
+
285
+ db_path = Path(state_dir) / "state.db"
286
+ if not db_path.exists():
287
+ return
288
+
289
+ event_store = store.EventStore(str(db_path))
290
+
291
+ for slug, entry in journal_state.items():
292
+ old_instance_id = entry.get("instance_id")
293
+ if old_instance_id:
294
+ try:
295
+ coordination.release(event_store, resource=slug, instance_id=old_instance_id)
296
+ except Exception:
297
+ # Ignore release errors; fail-closed.
298
+ pass
299
+ except Exception:
300
+ # Fail-closed: if coordination is unavailable, continue without release.
301
+ pass
302
+
303
+
304
+ def run_wave(
305
+ driver: AgentDriver,
306
+ manifest: Dict[str, Any],
307
+ *,
308
+ state_dir: Optional[str] = None,
309
+ git: Optional[Dict[str, str]] = None,
310
+ resume_journal: bool = False,
311
+ ) -> Dict[str, Any]:
312
+ """Run a full multi-item wave through an AgentDriver backend.
313
+
314
+ Implements the complete wave algorithm: preflight ownership guard, parallel
315
+ build, bounded repair, optional adversarial review, and batched git ship.
316
+
317
+ Supports resumable waves: if resume_journal=True and state_dir exists,
318
+ skips items marked as verified in the journal and does trust-but-verify
319
+ re-running of their tests. Releases stale leases from dead instances.
320
+
321
+ Args:
322
+ driver: AgentDriver instance providing dispatch_worker, run_command, etc.
323
+ manifest: dict with:
324
+ - items: list of item dicts with {slug, ownsFiles, prompt, testCmd, workDir, ...}
325
+ - (optional) other manifest fields
326
+ state_dir: optional path to state directory for coordination claims and
327
+ cost_ceiling ledger. If None, these features are skipped.
328
+ git: optional dict with {expectTopLevel: str} for git operations. If None,
329
+ ship phase is skipped.
330
+ resume_journal: if True and state_dir exists, load journal and skip items
331
+ marked as verified (but re-run their tests for trust-but-verify).
332
+
333
+ Returns:
334
+ dict with structure:
335
+ {
336
+ "preflight_ok": bool,
337
+ "aborted": bool,
338
+ "abort_reason": str or None,
339
+ "built": [
340
+ {
341
+ "slug": str,
342
+ "dispatched": bool,
343
+ "verified": bool,
344
+ "testExit": int or None,
345
+ "repairs": int,
346
+ "error": str or None,
347
+ "filesWritten": [str],
348
+ "skipped_from_journal": bool (only if resume_journal=True),
349
+ },
350
+ ...
351
+ ],
352
+ "shipped": [str] or None (list of slugs, or None if git not configured),
353
+ "ceiling": dict or None (from cost_ceiling.check, or None if no ceiling),
354
+ "policy": dict (the resolved verification_policy),
355
+ "resume_stats": dict (only if resume_journal=True) with:
356
+ {
357
+ "skipped_from_journal": int,
358
+ "rebuilt": int,
359
+ }
360
+ }
361
+
362
+ Fail-safe invariants:
363
+ - Verified is True ONLY from run_command exit code 0.
364
+ - Any exception in an item's dispatch -> verified=False for that item.
365
+ - Cost ceiling: if check() says exceeded, abort immediately with no more dispatch.
366
+ - Disjoint ownership: any overlap -> abort with structured error, no dispatch.
367
+ """
368
+ result = {
369
+ "preflight_ok": False,
370
+ "aborted": False,
371
+ "abort_reason": None,
372
+ "built": [],
373
+ "shipped": None,
374
+ "ceiling": None,
375
+ "policy": None,
376
+ "resume_stats": None,
377
+ }
378
+
379
+ # Extract items from manifest.
380
+ items = manifest.get("items", [])
381
+
382
+ # ========================================================================
383
+ # PHASE 0 (optional): Resume - Load journal state and release stale leases
384
+ # ========================================================================
385
+ journal_state = {}
386
+ resume_stats = {"skipped_from_journal": 0, "rebuilt": 0}
387
+ resume_stats_lock = threading.Lock() # Protect resume_stats from concurrent access
388
+ if resume_journal and state_dir:
389
+ journal_state = _load_journal_state(state_dir)
390
+ _release_stale_leases(state_dir, journal_state)
391
+ if journal_state:
392
+ result["resume_stats"] = resume_stats
393
+
394
+ # ========================================================================
395
+ # PHASE 1: Preflight ownership guard
396
+ # ========================================================================
397
+ owner_map = {} # normalized file -> slug
398
+ conflicts = []
399
+
400
+ for item in items:
401
+ slug = item.get("slug", "unknown")
402
+ owned_files = item.get("ownsFiles", [])
403
+ for f in owned_files:
404
+ # Platform-independent path normalization: handle separators and case uniformly.
405
+ # Replace all backslashes with forward slashes, normalize with posixpath,
406
+ # and convert to lowercase for case-insensitive comparison on all platforms.
407
+ normalized = posixpath.normpath(f.replace("\\", "/")).lower()
408
+ if normalized in owner_map:
409
+ conflicts.append(
410
+ {
411
+ "file": f,
412
+ "normalized": normalized,
413
+ "items": [owner_map[normalized], slug],
414
+ }
415
+ )
416
+ else:
417
+ owner_map[normalized] = slug
418
+
419
+ if conflicts:
420
+ result["aborted"] = True
421
+ result["abort_reason"] = "ownership_overlap"
422
+ result["conflicts"] = conflicts
423
+ return result
424
+
425
+ result["preflight_ok"] = True
426
+
427
+ # ========================================================================
428
+ # PHASE 2: Resolve verification policy ONCE
429
+ # ========================================================================
430
+ caps = driver.probe_capabilities()
431
+ policy = verification_policy(caps)
432
+ result["policy"] = policy
433
+
434
+ repair_cap = policy.get("repair_cap", 1)
435
+ spot_check_frac = policy.get("spot_check_frac", 0.0)
436
+ require_adversarial_review = policy.get("require_adversarial_review", False)
437
+
438
+ # ========================================================================
439
+ # PHASE 3: Cost-ceiling gate (before build)
440
+ # ========================================================================
441
+ if cost_ceiling is not None and state_dir is not None:
442
+ ceiling_result = cost_ceiling.check(
443
+ spent=driver.get_tokens_spent(),
444
+ trip=True,
445
+ state_dir=state_dir,
446
+ )
447
+ result["ceiling"] = ceiling_result
448
+
449
+ if ceiling_result.get("exceeded", False):
450
+ result["aborted"] = True
451
+ result["abort_reason"] = "cost_ceiling_exceeded"
452
+ return result
453
+
454
+ # ========================================================================
455
+ # PHASE 4: Build (PARALLEL with ThreadPoolExecutor)
456
+ # ========================================================================
457
+ # Prepare built items list and track for repair.
458
+ built_items = []
459
+ failed_items = [] # (index, item, result) tuples for repair
460
+
461
+ def build_item(item_index: int, item: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]:
462
+ """Build one item and return (index, result_dict)."""
463
+ slug = item.get("slug", f"item-{item_index}")
464
+ workdir = item.get("workDir", ".")
465
+
466
+ # ====================================================================
467
+ # RESUME CHECK: If in journal and verified, skip dispatch and trust-verify
468
+ # ====================================================================
469
+ journal_entry = journal_state.get(slug)
470
+ skipped_from_journal = False
471
+ if journal_entry and _should_skip_from_journal(journal_entry):
472
+ skipped_from_journal = True
473
+ with resume_stats_lock:
474
+ resume_stats["skipped_from_journal"] += 1
475
+
476
+ # Trust-but-verify: re-run the test for the journaled item
477
+ test_cmd = item.get("testCmd", "")
478
+ if test_cmd:
479
+ try:
480
+ test_result = driver.run_command(test_cmd, cwd=workdir)
481
+ if test_result.exit_code == 0:
482
+ # Test still passes; mark verified.
483
+ return (
484
+ item_index,
485
+ {
486
+ "slug": slug,
487
+ "dispatched": False,
488
+ "verified": True,
489
+ "testExit": 0,
490
+ "repairs": 0,
491
+ "error": None,
492
+ "filesWritten": [],
493
+ "skipped_from_journal": True,
494
+ "trust_verified": True,
495
+ },
496
+ )
497
+ else:
498
+ # Test failed on re-run; flip to False and mark for rebuild.
499
+ with resume_stats_lock:
500
+ resume_stats["rebuilt"] += 1
501
+ return (
502
+ item_index,
503
+ {
504
+ "slug": slug,
505
+ "dispatched": False,
506
+ "verified": False,
507
+ "testExit": test_result.exit_code,
508
+ "repairs": 0,
509
+ "error": "trust-verify test failed (re-run)",
510
+ "filesWritten": [],
511
+ "skipped_from_journal": True,
512
+ "trust_verified": False,
513
+ },
514
+ )
515
+ except Exception as exc:
516
+ # Test re-run failed; flip to False and mark for rebuild.
517
+ with resume_stats_lock:
518
+ resume_stats["rebuilt"] += 1
519
+ return (
520
+ item_index,
521
+ {
522
+ "slug": slug,
523
+ "dispatched": False,
524
+ "verified": False,
525
+ "testExit": None,
526
+ "repairs": 0,
527
+ "error": f"trust-verify exception: {exc}",
528
+ "filesWritten": [],
529
+ "skipped_from_journal": True,
530
+ "trust_verified": False,
531
+ },
532
+ )
533
+ else:
534
+ # No test command; just mark as skipped but not verified (safe).
535
+ return (
536
+ item_index,
537
+ {
538
+ "slug": slug,
539
+ "dispatched": False,
540
+ "verified": False,
541
+ "testExit": None,
542
+ "repairs": 0,
543
+ "error": "no test command for trust-verify",
544
+ "filesWritten": [],
545
+ "skipped_from_journal": True,
546
+ "trust_verified": False,
547
+ },
548
+ )
549
+
550
+ # ====================================================================
551
+ # NORMAL BUILD: Not in journal or was marked as failed
552
+ # ====================================================================
553
+ with resume_stats_lock:
554
+ resume_stats["rebuilt"] += 1
555
+
556
+ # Try to claim the item if state_dir is given (fail-closed on claim failure).
557
+ instance_id = f"wave-{uuid.uuid4()}"
558
+ claim_held = False
559
+ if coordination is not None and state_dir is not None:
560
+ try:
561
+ from state_store import store
562
+ db_path = Path(state_dir) / "state.db"
563
+ event_store = store.EventStore(str(db_path))
564
+ claim_held = coordination.try_claim(
565
+ event_store,
566
+ resource=slug,
567
+ instance_id=instance_id,
568
+ )
569
+ if not claim_held:
570
+ # Item is claimed by another instance; skip it.
571
+ return (
572
+ item_index,
573
+ {
574
+ "slug": slug,
575
+ "dispatched": False,
576
+ "verified": False,
577
+ "testExit": None,
578
+ "repairs": 0,
579
+ "error": "resource claimed by another instance",
580
+ "filesWritten": [],
581
+ },
582
+ )
583
+ except Exception:
584
+ # On exception, fail-closed: skip the item.
585
+ claim_held = False
586
+
587
+ try:
588
+ # Build the manifest item with policy.
589
+ manifest_item = build_manifest_item(driver, item)
590
+
591
+ # Dispatch the item.
592
+ dispatch_result = dispatch_item(driver, manifest_item, workdir=workdir)
593
+
594
+ item_result = {
595
+ "slug": slug,
596
+ "dispatched": dispatch_result.get("route") == "driver",
597
+ "verified": dispatch_result.get("verified", False),
598
+ "testExit": dispatch_result.get("testExit"),
599
+ "repairs": 0,
600
+ "error": dispatch_result.get("error"),
601
+ "filesWritten": dispatch_result.get("filesWritten", []),
602
+ "workerId": dispatch_result.get("workerId"),
603
+ }
604
+
605
+ # Write journal entry for this item's outcome.
606
+ if state_dir:
607
+ _write_journal_entry(state_dir, slug, "dispatched", {
608
+ "verified": item_result["verified"],
609
+ "testExit": item_result["testExit"],
610
+ "instance_id": instance_id,
611
+ })
612
+
613
+ return (item_index, item_result)
614
+
615
+ except Exception as exc:
616
+ # Catch-all: any exception -> failed result, never a false green.
617
+ if state_dir:
618
+ _write_journal_entry(state_dir, slug, "failed", {
619
+ "verified": False,
620
+ "testExit": None,
621
+ "instance_id": instance_id,
622
+ "error": str(exc),
623
+ })
624
+
625
+ return (
626
+ item_index,
627
+ {
628
+ "slug": slug,
629
+ "dispatched": False,
630
+ "verified": False,
631
+ "testExit": None,
632
+ "repairs": 0,
633
+ "error": f"build exception: {exc}",
634
+ "filesWritten": [],
635
+ },
636
+ )
637
+ finally:
638
+ # Release the claim if held.
639
+ if claim_held and coordination is not None and state_dir is not None:
640
+ try:
641
+ from state_store import store
642
+ db_path = Path(state_dir) / "state.db"
643
+ event_store = store.EventStore(str(db_path))
644
+ coordination.release(event_store, resource=slug, instance_id=instance_id)
645
+ except Exception:
646
+ pass
647
+
648
+ # Run build in parallel.
649
+ max_workers = min(8, len(items)) if items else 1
650
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
651
+ futures = [
652
+ executor.submit(build_item, i, item)
653
+ for i, item in enumerate(items)
654
+ ]
655
+ for future in concurrent.futures.as_completed(futures):
656
+ try:
657
+ item_index, item_result = future.result()
658
+ built_items.append((item_index, items[item_index], item_result))
659
+
660
+ # Track failed items for repair.
661
+ if not item_result["verified"]:
662
+ failed_items.append((item_index, items[item_index], item_result))
663
+ except Exception:
664
+ # Should not happen (build_item catches internally), but just in case.
665
+ pass
666
+
667
+ # Sort built_items by index to preserve order.
668
+ built_items.sort(key=lambda x: x[0])
669
+ result["built"] = [item_result for _, _, item_result in built_items]
670
+
671
+ # ========================================================================
672
+ # PHASE 5: Bounded repair
673
+ # ========================================================================
674
+ for repair_round in range(repair_cap):
675
+ if not failed_items:
676
+ break
677
+
678
+ # Cost-ceiling check before repair round.
679
+ if cost_ceiling is not None and state_dir is not None:
680
+ ceiling_result = cost_ceiling.check(
681
+ spent=driver.get_tokens_spent(),
682
+ trip=True,
683
+ state_dir=state_dir,
684
+ )
685
+ if ceiling_result.get("exceeded", False):
686
+ result["aborted"] = True
687
+ result["abort_reason"] = "cost_ceiling_exceeded_in_repair"
688
+ return result
689
+
690
+ # Repair each failed item.
691
+ next_failed = []
692
+ for item_index, item, item_result in failed_items:
693
+ slug = item.get("slug", f"item-{item_index}")
694
+ workdir = item.get("workDir", ".")
695
+ test_cmd = item.get("testCmd", "")
696
+
697
+ # Build repair prompt: append test output to original prompt.
698
+ original_prompt = item.get("prompt", "")
699
+ test_output = f"\n\nTest failed with exit code {item_result['testExit']}.\n"
700
+ if item_result.get("error"):
701
+ test_output += f"Error: {item_result['error']}\n"
702
+ repair_prompt = original_prompt + test_output
703
+
704
+ # Create a repair item.
705
+ repair_item = dict(item)
706
+ repair_item["prompt"] = repair_prompt
707
+
708
+ try:
709
+ # Build the manifest item.
710
+ manifest_item = build_manifest_item(driver, repair_item)
711
+
712
+ # Dispatch the repair.
713
+ dispatch_result = dispatch_item(driver, manifest_item, workdir=workdir)
714
+
715
+ # Update the item result.
716
+ item_result["verified"] = dispatch_result.get("verified", False)
717
+ item_result["testExit"] = dispatch_result.get("testExit")
718
+ item_result["error"] = dispatch_result.get("error")
719
+ item_result["filesWritten"] = dispatch_result.get("filesWritten", [])
720
+ item_result["repairs"] += 1
721
+
722
+ # Write journal entry for repair outcome.
723
+ if state_dir:
724
+ _write_journal_entry(state_dir, slug, "repaired", {
725
+ "verified": item_result["verified"],
726
+ "testExit": item_result["testExit"],
727
+ "repairs": item_result["repairs"],
728
+ })
729
+
730
+ # If still failed, mark for next round.
731
+ if not item_result["verified"]:
732
+ next_failed.append((item_index, item, item_result))
733
+
734
+ except Exception as exc:
735
+ item_result["error"] = f"repair exception: {exc}"
736
+ item_result["repairs"] += 1
737
+
738
+ # Write journal entry for repair failure.
739
+ if state_dir:
740
+ _write_journal_entry(state_dir, slug, "repair_failed", {
741
+ "verified": False,
742
+ "testExit": None,
743
+ "repairs": item_result["repairs"],
744
+ "error": str(exc),
745
+ })
746
+
747
+ next_failed.append((item_index, item, item_result))
748
+
749
+ # Update failed_items for next round.
750
+ failed_items = next_failed
751
+
752
+ # ========================================================================
753
+ # PHASE 5.5: Spot-check verified items (if spot_check_frac > 0)
754
+ # ========================================================================
755
+ if spot_check_frac > 0:
756
+ # Collect verified items and their original test commands.
757
+ verified_items_to_check = []
758
+ for item_index, (idx, original_item, item_result) in enumerate(
759
+ [(i, items[i], r) for i, r in enumerate(result["built"]) if r.get("verified", False)]
760
+ ):
761
+ verified_items_to_check.append((original_item, item_result))
762
+
763
+ # Determine how many to spot-check.
764
+ num_to_check = ceil(len(verified_items_to_check) * spot_check_frac)
765
+
766
+ # Deterministic sampling: check first N items by slug order.
767
+ # Sort by slug for determinism, then check the first num_to_check.
768
+ verified_items_to_check.sort(key=lambda x: x[0].get("slug", ""))
769
+ items_to_rerun = verified_items_to_check[:num_to_check]
770
+
771
+ # Re-run tests for sampled items.
772
+ for original_item, item_result in items_to_rerun:
773
+ test_cmd = original_item.get("testCmd", "")
774
+ workdir = original_item.get("workDir", ".")
775
+
776
+ if test_cmd:
777
+ try:
778
+ rerun_result = driver.run_command(test_cmd, cwd=workdir)
779
+ # If re-run does NOT exit 0, flip verified to False.
780
+ if rerun_result.exit_code != 0:
781
+ item_result["verified"] = False
782
+ item_result["spot_check_failed"] = True
783
+ except Exception:
784
+ # On exception, flip verified to False.
785
+ item_result["verified"] = False
786
+ item_result["spot_check_failed"] = True
787
+
788
+ # ========================================================================
789
+ # PHASE 6: Adversarial review (deferred, not yet enforced)
790
+ # ========================================================================
791
+ # Adversarial review is not yet implemented; mark all as deferred.
792
+ # (TODO in a later increment: real adversarial review dispatch via driver)
793
+ result["adversarial_review"] = "deferred"
794
+ for item_result in result["built"]:
795
+ item_result["adversarial_review"] = "deferred"
796
+
797
+ # ========================================================================
798
+ # PHASE 7: Batched ship (git operations, if configured)
799
+ # ========================================================================
800
+ if git is not None:
801
+ # Verify expectTopLevel guard: MUST be a non-empty string matching actual toplevel.
802
+ expect_top_level = git.get("expectTopLevel")
803
+ if not expect_top_level or not isinstance(expect_top_level, str):
804
+ # Empty or missing expectTopLevel with git config is an error.
805
+ result["aborted"] = True
806
+ result["abort_reason"] = "git_toplevel_missing_or_empty"
807
+ return result
808
+
809
+ toplevel_result = driver.run_command("git rev-parse --show-toplevel")
810
+ if toplevel_result.exit_code != 0:
811
+ result["aborted"] = True
812
+ result["abort_reason"] = "git_toplevel_check_failed"
813
+ return result
814
+
815
+ toplevel = toplevel_result.stdout.strip()
816
+ if toplevel != expect_top_level:
817
+ result["aborted"] = True
818
+ result["abort_reason"] = "git_toplevel_mismatch"
819
+ return result
820
+
821
+ # Only ship items that verified green.
822
+ verified_items = [
823
+ item_result for item_result in result["built"]
824
+ if item_result.get("verified", False)
825
+ ]
826
+
827
+ if verified_items:
828
+ # Stage and commit.
829
+ files_to_add = []
830
+ for item_result in verified_items:
831
+ files_to_add.extend(item_result.get("filesWritten", []))
832
+
833
+ if files_to_add:
834
+ # Add files. Escape each filename to prevent shell injection.
835
+ # Use _quote_arg for platform-safe quoting (Windows + POSIX).
836
+ escaped_files = [_quote_arg(f) for f in files_to_add]
837
+ add_cmd = "git add " + " ".join(escaped_files)
838
+ add_result = driver.run_command(add_cmd)
839
+ if add_result.exit_code != 0:
840
+ result["aborted"] = True
841
+ result["abort_reason"] = "git_add_failed"
842
+ return result
843
+
844
+ # Commit. Escape the message to prevent shell injection.
845
+ # Use _quote_arg for platform-safe quoting.
846
+ commit_msg = f"Wave: {len(verified_items)} items verified"
847
+ commit_cmd = f"git commit -m {_quote_arg(commit_msg)}"
848
+ commit_result = driver.run_command(commit_cmd)
849
+ if commit_result.exit_code != 0:
850
+ # Might be "nothing to commit"; not necessarily a failure.
851
+ pass
852
+
853
+ # Push.
854
+ push_result = driver.run_command("git push")
855
+ if push_result.exit_code != 0:
856
+ result["aborted"] = True
857
+ result["abort_reason"] = "git_push_failed"
858
+ return result
859
+
860
+ # Record shipped items.
861
+ result["shipped"] = [item_result["slug"] for item_result in verified_items]
862
+
863
+ return result
864
+
865
+
866
+ def result_to_report(wave_result: Dict[str, Any]) -> Dict[str, Any]:
867
+ """Convert run_wave result dict to fleet_ledger Report JSON format.
868
+
869
+ The Report JSON is compatible with `fleet_ledger.py append-wave` and contains:
870
+ - tokens: {buildOut, verifyOut, repairOut, totalOut}
871
+ - integration: {green: bool, ...}
872
+ - repairsUsed: int
873
+ - built: [item results]
874
+ - preflight_ok: bool
875
+ - aborted: bool
876
+
877
+ Args:
878
+ wave_result: dict returned from run_wave()
879
+
880
+ Returns:
881
+ dict in fleet_ledger Report format
882
+ """
883
+ built_items = wave_result.get("built", [])
884
+ repairs_used = sum(item.get("repairs", 0) for item in built_items)
885
+
886
+ # Determine if wave was fully green (all items verified and not aborted).
887
+ green = not wave_result.get("aborted", False) and all(
888
+ item.get("verified", False) for item in built_items
889
+ )
890
+
891
+ report = {
892
+ "tokens": {
893
+ "buildOut": 100, # Placeholder; driver should track real tokens
894
+ "verifyOut": 0,
895
+ "repairOut": repairs_used * 50 if repairs_used > 0 else 0,
896
+ "totalOut": 100 + repairs_used * 50,
897
+ },
898
+ "integration": {
899
+ "green": green,
900
+ },
901
+ "repairsUsed": repairs_used,
902
+ "built": built_items,
903
+ "preflight_ok": wave_result.get("preflight_ok", False),
904
+ "aborted": wave_result.get("aborted", False),
905
+ }
906
+
907
+ return report
908
+
909
+
910
+ def main():
911
+ """CLI entrypoint for one-turn wave mode.
912
+
913
+ Usage:
914
+ python -m driver.wave_loop --manifest <path> [--one-turn] [--state-dir <path>] [--output <path>]
915
+
916
+ The --one-turn flag enables the complete wave sequence in one invocation.
917
+ Output is JSON (either to stdout or --output file).
918
+ """
919
+ import argparse
920
+
921
+ parser = argparse.ArgumentParser(
922
+ description="One-turn wave mode: run a complete wave (preflight - build - verify - repair - report)",
923
+ formatter_class=argparse.RawDescriptionHelpFormatter,
924
+ epilog="""
925
+ Examples:
926
+ python driver/wave_loop.py --manifest wave.json --one-turn
927
+ python driver/wave_loop.py --manifest wave.json --one-turn --output report.json
928
+ python driver/wave_loop.py --manifest wave.json --one-turn --state-dir ./state
929
+ """,
930
+ )
931
+
932
+ parser.add_argument(
933
+ "--manifest",
934
+ required=True,
935
+ type=str,
936
+ help="Path to wave manifest JSON file (required)",
937
+ )
938
+ parser.add_argument(
939
+ "--one-turn",
940
+ action="store_true",
941
+ help="Run the complete wave in one turn (preflight - build - verify - repair - report)",
942
+ )
943
+ parser.add_argument(
944
+ "--state-dir",
945
+ type=str,
946
+ default=None,
947
+ help="Path to state directory for coordination/cost tracking (optional)",
948
+ )
949
+ parser.add_argument(
950
+ "--output",
951
+ type=str,
952
+ default=None,
953
+ help="Output file for Report JSON (default: stdout)",
954
+ )
955
+ parser.add_argument(
956
+ "--git",
957
+ action="store_true",
958
+ help="Enable git operations (stage, commit, push verified items)",
959
+ )
960
+
961
+ args = parser.parse_args()
962
+
963
+ # Load manifest from JSON file.
964
+ try:
965
+ manifest_path = Path(args.manifest)
966
+ if not manifest_path.exists():
967
+ print(f"Error: manifest file not found: {args.manifest}", file=sys.stderr)
968
+ return 1
969
+
970
+ with open(manifest_path) as f:
971
+ manifest = json.load(f)
972
+ except json.JSONDecodeError as e:
973
+ print(f"Error: invalid JSON in manifest file: {e}", file=sys.stderr)
974
+ return 1
975
+ except Exception as e:
976
+ print(f"Error: failed to load manifest: {e}", file=sys.stderr)
977
+ return 1
978
+
979
+ # For now, use the Claude Code reference driver.
980
+ # In the future, this should be configurable via backend_config.
981
+ try:
982
+ from claude_code_driver import ClaudeCodeDriver
983
+ driver = ClaudeCodeDriver()
984
+ except ImportError:
985
+ print(
986
+ "Error: could not import ClaudeCodeDriver. "
987
+ "Ensure driver/ is on the Python path.",
988
+ file=sys.stderr,
989
+ )
990
+ return 1
991
+
992
+ # Prepare git config if --git flag is used.
993
+ git_config = None
994
+ if args.git:
995
+ # Get the current top-level directory as a guard.
996
+ toplevel_result = driver.run_command("git rev-parse --show-toplevel")
997
+ if toplevel_result.exit_code != 0:
998
+ print("Error: could not determine git top-level directory", file=sys.stderr)
999
+ return 1
1000
+ toplevel = toplevel_result.stdout.strip()
1001
+ git_config = {"expectTopLevel": toplevel}
1002
+
1003
+ # Run the wave.
1004
+ try:
1005
+ result = run_wave(
1006
+ driver,
1007
+ manifest,
1008
+ state_dir=args.state_dir,
1009
+ git=git_config,
1010
+ )
1011
+ except Exception as e:
1012
+ print(f"Error: wave execution failed: {e}", file=sys.stderr)
1013
+ return 1
1014
+
1015
+ # Convert result to Report JSON format.
1016
+ report = result_to_report(result)
1017
+
1018
+ # Output Report JSON.
1019
+ report_json = json.dumps(report, indent=2)
1020
+
1021
+ if args.output:
1022
+ try:
1023
+ with open(args.output, "w") as f:
1024
+ f.write(report_json)
1025
+ print(f"Report written to {args.output}", file=sys.stderr)
1026
+ except Exception as e:
1027
+ print(f"Error: failed to write report file: {e}", file=sys.stderr)
1028
+ return 1
1029
+ else:
1030
+ print(report_json)
1031
+
1032
+ # Return exit code based on wave status.
1033
+ # Exit 0 if wave completed (aborted or not), exit 1 if preflight failed.
1034
+ if not result.get("preflight_ok"):
1035
+ return 1
1036
+
1037
+ return 0
1038
+
1039
+
1040
+ if __name__ == "__main__":
1041
+ sys.exit(main())