@matt82198/aesop 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +60 -263
  3. package/bin/cli.js +7 -3
  4. package/daemons/install-tasks.ps1 +193 -0
  5. package/daemons/run-hidden.vbs +39 -0
  6. package/docs/HOOK-INSTALL.md +15 -56
  7. package/docs/INSTALL.md +48 -3
  8. package/docs/PORTING.md +166 -0
  9. package/docs/TEAM-STATE.md +16 -184
  10. package/docs/reproduce.md +33 -3
  11. package/driver/CLAUDE.md +50 -48
  12. package/driver/codex_driver.py +59 -12
  13. package/driver/openai_transport.py +33 -0
  14. package/driver/wave_bridge.py +9 -1
  15. package/driver/wave_loop.py +437 -70
  16. package/driver/wave_scheduler.py +890 -0
  17. package/hooks/pre-push-policy.sh +22 -5
  18. package/monitor/collect-signals.mjs +69 -0
  19. package/package.json +6 -4
  20. package/state_store/__init__.py +2 -1
  21. package/state_store/projections.py +63 -0
  22. package/state_store/read_api.py +156 -0
  23. package/state_store/write_api.py +462 -0
  24. package/tools/cost_ceiling.py +106 -15
  25. package/tools/cost_projection.py +559 -0
  26. package/tools/crossos_drift.py +394 -0
  27. package/tools/eod_sweep.py +137 -33
  28. package/tools/mutation_test.py +130 -8
  29. package/tools/proposals.mjs +47 -2
  30. package/tools/reproduce.js +405 -0
  31. package/tools/secret_scan.py +73 -18
  32. package/tools/stall_check.py +247 -16
  33. package/tools/stateapi_lint.py +325 -0
  34. package/tools/test_battery.py +173 -0
  35. package/tools/verify_cost_panel.py +345 -0
  36. package/tools/wave_preflight.py +296 -29
  37. package/tools/wave_templates.py +170 -45
  38. package/ui/collectors.py +81 -0
  39. package/ui/handler.py +230 -85
  40. package/ui/sse.py +3 -3
  41. package/ui/wave_telemetry.py +24 -18
  42. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  43. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  44. package/ui/web/dist/index.html +2 -2
  45. package/docs/QUICKSTART.md +0 -80
  46. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -38,9 +38,13 @@ import os
38
38
  import re
39
39
  import subprocess
40
40
  import sys
41
- import time
42
41
  from pathlib import Path
43
42
 
43
+ # Ensure both tools and state_store are importable (sys.path fix for bootstrapping)
44
+ repo_root = Path(__file__).parent.parent
45
+ if str(repo_root) not in sys.path:
46
+ sys.path.insert(0, str(repo_root))
47
+
44
48
  try:
45
49
  from common import get_state_dir, check_heartbeat_staleness
46
50
  except ImportError:
@@ -51,6 +55,9 @@ try:
51
55
  except ImportError:
52
56
  from tools import halt
53
57
 
58
+ # Import ReadAPI unconditionally - import failure is a loud error
59
+ from state_store.read_api import ReadAPI
60
+
54
61
 
55
62
  def load_config(root_dir=None):
56
63
  """Load aesop.config.json from root, return dict (or {} if absent/bad)."""
@@ -134,8 +141,10 @@ def parse_orchestrator_status_phase(status_json_path):
134
141
  def check_orchestrator_status_freshness(status_json_path, threshold_s):
135
142
  """Check if orchestrator-status.json is fresh based on updated_at timestamp.
136
143
 
144
+ Uses ReadAPI (state_store.read_api) for all checks - single source of truth.
145
+
137
146
  Args:
138
- status_json_path: Path to orchestrator-status.json
147
+ status_json_path: Path to orchestrator-status.json (or state dir)
139
148
  threshold_s: Staleness threshold in seconds
140
149
 
141
150
  Returns:
@@ -144,30 +153,38 @@ def check_orchestrator_status_freshness(status_json_path, threshold_s):
144
153
  age_s (int): Age in seconds (0 if file missing/unreadable)
145
154
  info (str or None): Descriptive message if stale/missing, None if fresh
146
155
  """
147
- if not status_json_path.exists():
148
- return True, 0, "orchestrator-status.json file missing"
149
-
150
156
  try:
151
- data = json.loads(status_json_path.read_text(encoding="utf-8"))
152
- updated_at = data.get("updated_at")
157
+ # Determine state_dir: if status_json_path is the status file, use its parent
158
+ status_path = Path(status_json_path)
159
+ if status_path.name == "orchestrator-status.json":
160
+ state_dir = status_path.parent
161
+ else:
162
+ state_dir = status_path
163
+
164
+ api = ReadAPI(str(state_dir))
165
+ status = api.read_orchestrator_status()
166
+ if status is None:
167
+ return True, 0, "orchestrator-status.json file missing or unreadable"
168
+
169
+ # Check freshness
170
+ updated_at = status.get("updated_at")
153
171
  if not updated_at:
154
172
  return True, 0, "orchestrator-status.json missing updated_at field"
155
173
 
156
- # Parse ISO 8601 timestamp (handle both "Z" and "+00:00" timezone formats)
157
- import datetime
158
- # Normalize Z suffix to +00:00 for fromisoformat compatibility
174
+ # Parse ISO 8601 timestamp
175
+ from datetime import datetime, timezone
159
176
  normalized_ts = updated_at.replace("Z", "+00:00")
160
- updated_dt = datetime.datetime.fromisoformat(normalized_ts)
177
+ updated_dt = datetime.fromisoformat(normalized_ts)
161
178
  timestamp = updated_dt.timestamp()
162
179
 
180
+ import time
163
181
  age_seconds = int(time.time()) - int(timestamp)
164
182
 
165
183
  # Check for far-future timestamp (clock skew beyond tolerance)
166
- # More than 120s in the future is treated as stale, not clamped-to-fresh
167
184
  if age_seconds < -120:
168
185
  return True, 0, "orchestrator-status timestamp in future (clock skew)"
169
186
 
170
- # Clamp small negative ages to 0 (normal clock skew recovery)
187
+ # Clamp small negative ages to 0
171
188
  age_seconds = max(0, age_seconds)
172
189
 
173
190
  if age_seconds >= threshold_s:
@@ -259,6 +276,201 @@ def can_import_secret_scan():
259
276
  return False, str(e)
260
277
 
261
278
 
279
+ def scan_backlog_items(tracker_path, work_dir, ledger_path=None):
280
+ """Scan backlog items for risky conditions.
281
+
282
+ Analyzes todo items in tracker.json for:
283
+ (a) missing/empty owns_files (file ownership info)
284
+ (b) stale items (references to non-existent files)
285
+ (c) overlaps (two items owning same file)
286
+ (d) history signal (retry rate from ledger)
287
+
288
+ Args:
289
+ tracker_path: Path to state/tracker.json
290
+ work_dir: Working directory for resolving relative file paths
291
+ ledger_path: Optional path to ledger for history stats
292
+
293
+ Returns:
294
+ dict with structure:
295
+ {
296
+ "success": bool,
297
+ "items": [
298
+ {
299
+ "id": str,
300
+ "title": str,
301
+ "flags": [{"type": str, "detail": str}, ...]
302
+ },
303
+ ...
304
+ ],
305
+ "ledger_stats": dict or None,
306
+ "summary": str
307
+ }
308
+ """
309
+ tracker_path = Path(tracker_path)
310
+ work_dir = Path(work_dir)
311
+ result = {
312
+ "success": True,
313
+ "items": [],
314
+ "ledger_stats": None,
315
+ "summary": ""
316
+ }
317
+
318
+ # Load tracker.json
319
+ try:
320
+ if not tracker_path.exists():
321
+ result["success"] = False
322
+ result["summary"] = f"tracker file not found: {tracker_path}"
323
+ return result
324
+
325
+ tracker_data = json.loads(tracker_path.read_text(encoding="utf-8"))
326
+ if not isinstance(tracker_data, dict) or "items" not in tracker_data:
327
+ result["success"] = False
328
+ result["summary"] = "tracker.json invalid: missing 'items' key"
329
+ return result
330
+
331
+ items = tracker_data.get("items", [])
332
+ except (json.JSONDecodeError, IOError) as e:
333
+ result["success"] = False
334
+ result["summary"] = f"tracker.json unreadable: {e}"
335
+ return result
336
+
337
+ # Filter to todo items only
338
+ todo_items = [
339
+ item for item in items
340
+ if isinstance(item, dict) and item.get("status") == "todo"
341
+ ]
342
+
343
+ # Build ownership map for overlap detection
344
+ ownership_map = {} # file_path -> set of item_ids
345
+
346
+ # Process each todo item
347
+ for item in todo_items:
348
+ item_id = item.get("id", "unknown")
349
+ title = item.get("title", "")
350
+ flags = []
351
+
352
+ # Flag (a): Check for missing/empty owns_files
353
+ owns_files = item.get("owns_files", [])
354
+ if not owns_files or (isinstance(owns_files, list) and len(owns_files) == 0):
355
+ flags.append({
356
+ "type": "missing_ownership",
357
+ "detail": "Item has no owns_files; cannot dispatch safely"
358
+ })
359
+
360
+ # Flag (b): Check for stale items (files not found)
361
+ if owns_files and isinstance(owns_files, list):
362
+ for file_ref in owns_files:
363
+ file_path = work_dir / file_ref if not Path(file_ref).is_absolute() else Path(file_ref)
364
+ if not file_path.exists():
365
+ flags.append({
366
+ "type": "stale_reference",
367
+ "detail": f"Referenced file not found: {file_ref}"
368
+ })
369
+
370
+ # Track for overlap detection
371
+ file_key = str(file_path.resolve())
372
+ if file_key not in ownership_map:
373
+ ownership_map[file_key] = set()
374
+ ownership_map[file_key].add(item_id)
375
+
376
+ if flags or owns_files: # Only include items with flags or ownership info
377
+ result["items"].append({
378
+ "id": item_id,
379
+ "title": title,
380
+ "flags": flags
381
+ })
382
+
383
+ # Flag (c): Detect overlaps
384
+ for file_path, item_ids in ownership_map.items():
385
+ if len(item_ids) > 1:
386
+ for item_id in item_ids:
387
+ # Find the item and add overlap flag
388
+ for item_result in result["items"]:
389
+ if item_result["id"] == item_id:
390
+ overlapping_ids = sorted(item_ids - {item_id})
391
+ item_result["flags"].append({
392
+ "type": "ownership_overlap",
393
+ "detail": f"File shared with items: {', '.join(overlapping_ids)}"
394
+ })
395
+ break
396
+
397
+ # Flag (d): Load ledger stats if provided
398
+ if ledger_path:
399
+ ledger_path = Path(ledger_path)
400
+ if ledger_path.exists():
401
+ try:
402
+ ledger_stats = _analyze_ledger(ledger_path)
403
+ result["ledger_stats"] = ledger_stats
404
+ except Exception as e:
405
+ result["ledger_stats"] = {"error": str(e), "data": "DATA-UNAVAILABLE"}
406
+ else:
407
+ result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "ledger not found"}
408
+ else:
409
+ result["ledger_stats"] = {"data": "DATA-UNAVAILABLE", "note": "no ledger provided"}
410
+
411
+ # Summary
412
+ flagged_count = sum(1 for item in result["items"] if item["flags"])
413
+ result["summary"] = f"Scanned {len(todo_items)} todo items; {flagged_count} have risk flags"
414
+
415
+ return result
416
+
417
+
418
+ def _analyze_ledger(ledger_path):
419
+ """Extract repair/retry statistics from ledger.
420
+
421
+ Returns:
422
+ dict with overall repair rate and stats
423
+ """
424
+ ledger_path = Path(ledger_path)
425
+ try:
426
+ content = ledger_path.read_text(encoding="utf-8")
427
+ except IOError:
428
+ return {"data": "DATA-UNAVAILABLE", "error": "unreadable"}
429
+
430
+ lines = content.split("\n")
431
+ entries = []
432
+
433
+ for line in lines:
434
+ # Skip header and separator lines
435
+ if not line.strip() or "---|" in line or not line.startswith("|"):
436
+ continue
437
+
438
+ # Parse markdown table row
439
+ cells = [c.strip() for c in line.split("|")[1:-1]]
440
+ if len(cells) < 7:
441
+ continue
442
+
443
+ # Skip header row (first cell is "ISO ts" or similar header name)
444
+ if cells[0] in ("ISO ts", "iso_ts") or cells[0].startswith("ISO"):
445
+ continue
446
+
447
+ try:
448
+ # Columns: ISO ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict, phase, wave
449
+ verdict = cells[6] if len(cells) > 6 else "OK"
450
+ phase = cells[7].strip() if len(cells) > 7 else None
451
+ entries.append({"verdict": verdict, "phase": phase})
452
+ except (ValueError, IndexError):
453
+ continue
454
+
455
+ if not entries:
456
+ return {"data": "DATA-UNAVAILABLE", "reason": "no ledger entries"}
457
+
458
+ # Calculate retry rate: entries with FAILED verdict / total
459
+ failed_count = sum(1 for e in entries if e.get("verdict") == "FAILED")
460
+ total_count = len(entries)
461
+ repair_count = sum(1 for e in entries if e.get("phase") == "repair")
462
+
463
+ retry_rate = failed_count / total_count if total_count > 0 else 0
464
+
465
+ return {
466
+ "total_entries": total_count,
467
+ "failed_count": failed_count,
468
+ "repair_count": repair_count,
469
+ "retry_rate": round(retry_rate, 3),
470
+ "data": "AVAILABLE"
471
+ }
472
+
473
+
262
474
  def run_checks(root_dir=None, state_dir=None, config=None):
263
475
  """Run all preflight checks.
264
476
 
@@ -447,6 +659,8 @@ def main(argv=None):
447
659
 
448
660
  root_dir = None
449
661
  state_dir = None
662
+ tracker_path = None
663
+ ledger_path = None
450
664
  output_format = "text"
451
665
 
452
666
  # Parse arguments
@@ -469,6 +683,22 @@ def main(argv=None):
469
683
  elif arg.startswith("--state-root="):
470
684
  state_dir = arg[len("--state-root="):]
471
685
  i += 1
686
+ elif arg == "--tracker":
687
+ i += 1
688
+ if i < len(argv):
689
+ tracker_path = argv[i]
690
+ i += 1
691
+ elif arg.startswith("--tracker="):
692
+ tracker_path = arg[len("--tracker="):]
693
+ i += 1
694
+ elif arg == "--ledger":
695
+ i += 1
696
+ if i < len(argv):
697
+ ledger_path = argv[i]
698
+ i += 1
699
+ elif arg.startswith("--ledger="):
700
+ ledger_path = arg[len("--ledger="):]
701
+ i += 1
472
702
  elif arg == "--json":
473
703
  output_format = "json"
474
704
  i += 1
@@ -487,25 +717,62 @@ def main(argv=None):
487
717
  else:
488
718
  state_dir = Path(state_dir)
489
719
 
490
- result = run_checks(root_dir, state_dir, config)
720
+ # If --tracker provided, run backlog analysis instead of repo readiness checks
721
+ if tracker_path:
722
+ work_dir = root_dir # Use root_dir as the working directory for file resolution
723
+ result = scan_backlog_items(tracker_path, work_dir, ledger_path)
491
724
 
492
- if output_format == "json":
493
- print(json.dumps(result, indent=2))
494
- else:
495
- # Text format: numbered list
496
- print("Wave preflight checks:")
497
- for i, check in enumerate(result["checks"], 1):
498
- status = "PASS" if check["ok"] else "FAIL"
499
- print(f"{i}. {check['name']}: {status}")
500
- if check["detail"]:
501
- print(f" {check['detail']}")
502
-
503
- if result["ready"]:
504
- print("\nPASS: Ready for wave")
725
+ if output_format == "json":
726
+ print(json.dumps(result, indent=2))
505
727
  else:
506
- print("\nFAIL: Not ready for wave (see failures above)")
728
+ # Text format: summary + items with flags
729
+ print(f"Backlog Preflight: {result['summary']}")
730
+ print()
731
+
732
+ if result["ledger_stats"]:
733
+ stats = result["ledger_stats"]
734
+ if stats.get("data") == "AVAILABLE":
735
+ print(f"Ledger History: {stats['total_entries']} entries, "
736
+ f"{stats['failed_count']} failed, "
737
+ f"{stats['repair_count']} repairs, "
738
+ f"retry rate: {stats['retry_rate']}")
739
+ else:
740
+ print(f"Ledger History: DATA-UNAVAILABLE")
741
+ print()
742
+
743
+ if result["items"]:
744
+ print("Flagged Items:")
745
+ for item in result["items"]:
746
+ if item["flags"]:
747
+ print(f" [{item['id']}] {item['title']}")
748
+ for flag in item["flags"]:
749
+ print(f" - {flag['type']}: {flag['detail']}")
750
+ else:
751
+ print("No risky items detected.")
752
+
753
+ # Exit 0 always (advisory tool, never blocks)
754
+ return 0
755
+ else:
756
+ # Standard repo readiness checks
757
+ result = run_checks(root_dir, state_dir, config)
507
758
 
508
- return 0 if result["ready"] else 1
759
+ if output_format == "json":
760
+ print(json.dumps(result, indent=2))
761
+ else:
762
+ # Text format: numbered list
763
+ print("Wave preflight checks:")
764
+ for i, check in enumerate(result["checks"], 1):
765
+ status = "PASS" if check["ok"] else "FAIL"
766
+ print(f"{i}. {check['name']}: {status}")
767
+ if check["detail"]:
768
+ print(f" {check['detail']}")
769
+
770
+ if result["ready"]:
771
+ print("\nPASS: Ready for wave")
772
+ else:
773
+ print("\nFAIL: Not ready for wave (see failures above)")
774
+
775
+ return 0 if result["ready"] else 1
509
776
 
510
777
 
511
778
  if __name__ == "__main__":
@@ -29,7 +29,7 @@ import argparse
29
29
  import json
30
30
  import sys
31
31
  from pathlib import Path
32
- from typing import Dict, Any, List, Optional
32
+ from typing import Dict, Any, List, Optional, Tuple
33
33
 
34
34
  # Presets directory (relative to this file's location).
35
35
  TOOLS_DIR = Path(__file__).resolve().parent
@@ -101,20 +101,29 @@ def instantiate_template(
101
101
  return manifest
102
102
 
103
103
 
104
- def validate_manifest(manifest: Dict[str, Any]) -> None:
104
+ def validate_manifest(
105
+ manifest: Dict[str, Any],
106
+ allow_placeholders: bool = True,
107
+ require_testcmd: bool = True
108
+ ) -> None:
105
109
  """Validate manifest schema and invariants.
106
110
 
107
111
  Checks:
108
112
  - items array exists and is non-empty
109
- - each item has required fields (slug, prompt, ownsFiles)
110
- - no file ownership overlap
111
- - no placeholder strings remain
113
+ - each item has required fields (slug, prompt, ownsFiles, and optionally testCmd)
114
+ - no file ownership overlap (per-manifest)
115
+ - optionally: no placeholder strings remain (for instantiated manifests)
112
116
 
113
117
  Args:
114
118
  manifest: the manifest dict to validate
119
+ allow_placeholders: if False, reject unsubstituted placeholders (default: True,
120
+ for backward compatibility with presets). Set to False for
121
+ instantiated manifests.
122
+ require_testcmd: if True, require testCmd field (default: True, since the wave
123
+ engine needs it). Set to False to validate presets only.
115
124
 
116
125
  Raises:
117
- ValueError: if validation fails
126
+ ValueError: if validation fails with detailed error per item
118
127
  """
119
128
  if "items" not in manifest:
120
129
  raise ValueError("Manifest missing 'items' array")
@@ -123,65 +132,160 @@ def validate_manifest(manifest: Dict[str, Any]) -> None:
123
132
  if not isinstance(items, list) or len(items) == 0:
124
133
  raise ValueError("'items' must be a non-empty list")
125
134
 
126
- # Check required fields and validate structure.
127
- required_fields = {"slug", "prompt", "ownsFiles"}
135
+ # Core required fields (always required).
136
+ required_core_fields = {"slug", "prompt", "ownsFiles"}
137
+ # Optional fields for instantiated manifests.
138
+ optional_fields = {"testCmd", "workDir"} if require_testcmd else set()
139
+ required_fields = required_core_fields | ({"testCmd"} if require_testcmd else set())
140
+
128
141
  owner_map = {}
129
142
  conflicts = []
143
+ errors = []
130
144
 
131
- for item in items:
145
+ for item_idx, item in enumerate(items):
132
146
  if not isinstance(item, dict):
133
- raise ValueError(f"Item must be a dict, got {type(item)}")
147
+ errors.append(f"Item {item_idx}: must be a dict, got {type(item)}")
148
+ continue
134
149
 
135
- # Check required fields.
136
- for field in required_fields:
150
+ # Check required core fields.
151
+ for field in required_core_fields:
137
152
  if field not in item:
138
- raise ValueError(f"Item {item.get('slug', 'unknown')} missing '{field}'")
153
+ errors.append(f"Item {item_idx} ({item.get('slug', 'unknown')}): missing required field '{field}'")
154
+
155
+ # Check required testCmd if flag is set.
156
+ if require_testcmd and "testCmd" not in item:
157
+ errors.append(f"Item {item_idx} ({item.get('slug', 'unknown')}): missing required field 'testCmd'")
139
158
 
140
- slug = item["slug"]
141
- if not isinstance(slug, str) or not slug:
142
- raise ValueError(f"Item slug must be non-empty string, got {slug}")
159
+ slug = item.get("slug")
160
+ if slug is not None:
161
+ if not isinstance(slug, str) or not slug:
162
+ errors.append(f"Item {item_idx}: slug must be non-empty string, got {slug}")
143
163
 
144
164
  # Check ownsFiles.
145
165
  owned_files = item.get("ownsFiles", [])
146
- if not isinstance(owned_files, list) or not owned_files:
147
- raise ValueError(f"Item {slug} must have non-empty ownsFiles list")
148
-
149
- # Track file ownership.
166
+ if not isinstance(owned_files, list):
167
+ errors.append(f"Item {item_idx} ({slug}): ownsFiles must be a list")
168
+ continue
169
+ if len(owned_files) == 0:
170
+ errors.append(f"Item {item_idx} ({slug}): ownsFiles must be non-empty")
171
+ continue
172
+
173
+ # Track file ownership (within this manifest only).
150
174
  for f in owned_files:
151
175
  if f in owner_map:
152
176
  conflicts.append((f, owner_map[f], slug))
153
177
  else:
154
178
  owner_map[f] = slug
155
179
 
180
+ # Report all collected errors first.
181
+ if errors:
182
+ error_msg = "Manifest validation failed:\n " + "\n ".join(errors)
183
+ raise ValueError(error_msg)
184
+
156
185
  if conflicts:
157
- raise ValueError(f"File ownership overlap: {conflicts}")
186
+ conflict_msg = "File ownership overlap (within manifest):\n " + "\n ".join(
187
+ [f"{f!r}: owned by both {o1!r} and {o2!r}" for f, o1, o2 in conflicts]
188
+ )
189
+ raise ValueError(conflict_msg)
190
+
191
+ # Check no placeholders remain (if instantiated manifest).
192
+ if not allow_placeholders:
193
+ manifest_str = json.dumps(manifest)
194
+ if "{project_name}" in manifest_str or "{base_dir}" in manifest_str:
195
+ raise ValueError("Manifest contains unsubstituted placeholders (not fully instantiated)")
196
+
158
197
 
159
- # Check no placeholders remain.
160
- manifest_str = json.dumps(manifest)
161
- if "{project_name}" in manifest_str or "{base_dir}" in manifest_str:
162
- raise ValueError("Manifest contains unsubstituted placeholders")
198
+ def validate_presets(preset_names: List[str], output_json: bool = False) -> Tuple[bool, List[str]]:
199
+ """Validate one or more presets.
200
+
201
+ Args:
202
+ preset_names: list of preset names to validate (e.g., ["saas", "data", "library"])
203
+
204
+ Returns:
205
+ tuple (success: bool, errors: List[str])
206
+ - success is True if all presets validate clean
207
+ - errors is a list of formatted error messages per preset/item
208
+ """
209
+ errors = []
210
+ all_valid = True
211
+ results = {}
212
+
213
+ for preset_name in preset_names:
214
+ try:
215
+ preset = load_preset(preset_name)
216
+ # Validate the preset: allow placeholders (presets have them), require testCmd (wave engine needs it)
217
+ validate_manifest(preset, allow_placeholders=True, require_testcmd=True)
218
+ results[preset_name] = {"valid": True, "errors": []}
219
+ print(f"✓ {preset_name}: valid", file=sys.stderr)
220
+ except FileNotFoundError as e:
221
+ errors.append(f"✗ {preset_name}: {e}")
222
+ all_valid = False
223
+ results[preset_name] = {"valid": False, "errors": [str(e)]}
224
+ except ValueError as e:
225
+ errors.append(f"✗ {preset_name}: {e}")
226
+ all_valid = False
227
+ results[preset_name] = {"valid": False, "errors": [str(e)]}
228
+ except Exception as e:
229
+ errors.append(f"✗ {preset_name}: unexpected error: {e}")
230
+ all_valid = False
231
+ results[preset_name] = {"valid": False, "errors": [str(e)]}
232
+
233
+ if output_json:
234
+ print(json.dumps({"ok": all_valid, "templates": results}))
235
+ else:
236
+ # Print errors if any
237
+ for error in errors:
238
+ print(error, file=sys.stderr)
239
+
240
+ return all_valid, errors
163
241
 
164
242
 
165
243
  def main():
166
- """CLI entry point: load preset and output resolved manifest."""
244
+ """CLI entry point: load preset, instantiate, validate, or validate presets."""
167
245
  parser = argparse.ArgumentParser(
168
- description="Load and instantiate a wave manifest preset"
246
+ description="Wave manifest preset manager: load, instantiate, and validate presets"
247
+ )
248
+
249
+ # Create subparsers for different commands
250
+ subparsers = parser.add_subparsers(dest="command", help="command to run")
251
+
252
+ # Subcommand: validate
253
+ validate_parser = subparsers.add_parser(
254
+ "validate",
255
+ help="validate preset template(s) for completeness"
256
+ )
257
+ validate_parser.add_argument(
258
+ "--template",
259
+ choices=["saas", "data", "library", "all"],
260
+ default="all",
261
+ help="which preset(s) to validate (default: all)"
262
+ )
263
+ validate_parser.add_argument(
264
+ "--json",
265
+ action="store_true",
266
+ help="output results in JSON format"
267
+ )
268
+
269
+ # Subcommand: instantiate
270
+ inst_parser = subparsers.add_parser(
271
+ "instantiate",
272
+ help="load and instantiate a preset manifest"
169
273
  )
170
- parser.add_argument(
274
+ inst_parser.add_argument(
171
275
  "preset",
172
276
  help="preset name: saas, data, or library"
173
277
  )
174
- parser.add_argument(
278
+ inst_parser.add_argument(
175
279
  "--project-name",
176
280
  required=True,
177
281
  help="project/app name (e.g., 'payment-api')"
178
282
  )
179
- parser.add_argument(
283
+ inst_parser.add_argument(
180
284
  "--base-dir",
181
285
  required=True,
182
286
  help="base working directory (e.g., '/workspace/my-app')"
183
287
  )
184
- parser.add_argument(
288
+ inst_parser.add_argument(
185
289
  "--output",
186
290
  default=None,
187
291
  help="output file (default: stdout)"
@@ -190,21 +294,42 @@ def main():
190
294
  args = parser.parse_args()
191
295
 
192
296
  try:
193
- # Load and instantiate.
194
- preset = load_preset(args.preset)
195
- manifest = instantiate_template(preset, args.project_name, args.base_dir)
196
-
197
- # Validate.
198
- validate_manifest(manifest)
199
-
200
- # Output.
201
- output_json = json.dumps(manifest, indent=2)
202
- if args.output:
203
- output_path = Path(args.output)
204
- output_path.write_text(output_json, encoding="utf-8")
205
- print(f"Manifest written to {output_path}", file=sys.stderr)
297
+ if args.command == "validate":
298
+ # Validate preset(s)
299
+ if args.template == "all":
300
+ presets = ["saas", "data", "library"]
301
+ else:
302
+ presets = [args.template]
303
+
304
+ success, errors = validate_presets(presets, output_json=args.json)
305
+
306
+ if not success:
307
+ sys.exit(1)
308
+ else:
309
+ print(f"\nAll {len(presets)} preset(s) validated successfully.", file=sys.stderr)
310
+ sys.exit(0)
311
+
312
+ elif args.command == "instantiate":
313
+ # Instantiate preset
314
+ preset = load_preset(args.preset)
315
+ manifest = instantiate_template(preset, args.project_name, args.base_dir)
316
+
317
+ # Validate the instantiated manifest (disallow placeholders, require testCmd)
318
+ validate_manifest(manifest, allow_placeholders=False, require_testcmd=True)
319
+
320
+ # Output.
321
+ output_json = json.dumps(manifest, indent=2)
322
+ if args.output:
323
+ output_path = Path(args.output)
324
+ output_path.write_text(output_json, encoding="utf-8")
325
+ print(f"Manifest written to {output_path}", file=sys.stderr)
326
+ else:
327
+ print(output_json)
328
+
206
329
  else:
207
- print(output_json)
330
+ # No command specified - print help
331
+ parser.print_help()
332
+ sys.exit(0)
208
333
 
209
334
  except Exception as e:
210
335
  print(f"Error: {e}", file=sys.stderr)