@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,215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Mid-wave recovery: classify completed vs remaining items from workflow journal.
4
+
5
+ Given a workflow run's journal.jsonl and its worktree, parses journal to identify
6
+ which items have already COMPLETED (files written + tests green) vs which remain,
7
+ enabling resume from the last good phase instead of full re-run.
8
+
9
+ Usage:
10
+ wave_resume.py --journal <path> --workdir <worktree> [--json]
11
+
12
+ Inputs:
13
+ --journal PATH Path to journal.jsonl (append-only log of task results)
14
+ --workdir PATH Path to worktree root
15
+ --json Output as JSON (default: human-readable summary)
16
+
17
+ Output:
18
+ {
19
+ "completed": ["item-slug-1", "item-slug-2"],
20
+ "remaining": ["item-slug-3", "item-slug-4"],
21
+ "resume_hint": "Resume from item: item-slug-3"
22
+ }
23
+
24
+ Logic:
25
+ 1. Parse journal.jsonl (each line = JSON task result)
26
+ 2. For each unique slug with status='completed':
27
+ - Verify all reported 'files' exist in workdir
28
+ - If all files exist, classify as completed
29
+ - Otherwise classify as remaining
30
+ 3. Emit resume plan (completed, remaining, hint)
31
+
32
+ Read-only: never mutates workdir or journal. Stdlib-only, Windows-safe paths.
33
+ """
34
+
35
+ import json
36
+ import argparse
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import List, Dict, Any, Optional
40
+
41
+
42
+ def load_journal(journal_path: str) -> List[Dict[str, Any]]:
43
+ """
44
+ Load and parse journal.jsonl file.
45
+
46
+ Each line is expected to be valid JSON representing a task result.
47
+ Malformed JSON lines are silently skipped.
48
+
49
+ Args:
50
+ journal_path: Path to journal.jsonl file
51
+
52
+ Returns:
53
+ List of parsed JSON records (in order), or empty list if file missing/unreadable
54
+ """
55
+ journal = []
56
+ path = Path(journal_path)
57
+
58
+ if not path.exists():
59
+ return journal
60
+
61
+ try:
62
+ with open(path, 'r', encoding='utf-8') as f:
63
+ for line in f:
64
+ line = line.strip()
65
+ if line:
66
+ try:
67
+ record = json.loads(line)
68
+ journal.append(record)
69
+ except json.JSONDecodeError:
70
+ # Silently skip malformed JSON lines
71
+ continue
72
+ except (IOError, OSError):
73
+ # File unreadable; return empty list
74
+ pass
75
+
76
+ return journal
77
+
78
+
79
+ def verify_files_exist(workdir: str, files: List[str]) -> bool:
80
+ """
81
+ Check if all files in the list exist under workdir.
82
+
83
+ Args:
84
+ workdir: Path to worktree root
85
+ files: List of relative file paths to verify
86
+
87
+ Returns:
88
+ True if all files exist (or list is empty), False if any missing
89
+ """
90
+ workdir_path = Path(workdir)
91
+
92
+ if not files:
93
+ # Empty list is considered valid (no files to verify)
94
+ return True
95
+
96
+ for file_path in files:
97
+ # Construct full path and check existence
98
+ full_path = workdir_path / file_path
99
+
100
+ # Normalize path to handle platform differences
101
+ try:
102
+ # Resolve to absolute path for safety
103
+ resolved = full_path.resolve()
104
+ if not resolved.exists():
105
+ return False
106
+ except (OSError, RuntimeError):
107
+ # Path resolution failed (e.g., invalid characters, symlink loops)
108
+ return False
109
+
110
+ return True
111
+
112
+
113
+ def classify_items(
114
+ journal: List[Dict[str, Any]], workdir: str
115
+ ) -> Dict[str, Any]:
116
+ """
117
+ Classify items as completed vs remaining based on journal + file verification.
118
+
119
+ For each unique slug in journal:
120
+ - If status='completed' AND all reported files exist in workdir: completed
121
+ - Otherwise: remaining
122
+
123
+ Args:
124
+ journal: List of task result records from load_journal()
125
+ workdir: Path to worktree root
126
+
127
+ Returns:
128
+ {
129
+ "completed": [list of slugs],
130
+ "remaining": [list of slugs],
131
+ "resume_hint": "Resume from item: <first-remaining-slug>"
132
+ }
133
+ """
134
+ completed = []
135
+ remaining = []
136
+ processed_slugs = set()
137
+
138
+ for record in journal:
139
+ slug = record.get("slug")
140
+
141
+ # Skip records without slug or already-processed slugs
142
+ if not slug or slug in processed_slugs:
143
+ continue
144
+
145
+ processed_slugs.add(slug)
146
+
147
+ status = record.get("status", "unknown")
148
+ files = record.get("files", [])
149
+
150
+ # An item is completed if:
151
+ # 1. status == 'completed'
152
+ # 2. files list is non-empty AND all files exist
153
+ if status == "completed" and files and verify_files_exist(workdir, files):
154
+ completed.append(slug)
155
+ else:
156
+ remaining.append(slug)
157
+
158
+ # Generate resume hint
159
+ if remaining:
160
+ resume_hint = f"Resume from item: {remaining[0]}"
161
+ else:
162
+ resume_hint = "All items completed"
163
+
164
+ return {
165
+ "completed": completed,
166
+ "remaining": remaining,
167
+ "resume_hint": resume_hint,
168
+ }
169
+
170
+
171
+ def main():
172
+ parser = argparse.ArgumentParser(
173
+ description="Mid-wave recovery: classify completed vs remaining items",
174
+ formatter_class=argparse.RawDescriptionHelpFormatter,
175
+ epilog=__doc__,
176
+ )
177
+ parser.add_argument(
178
+ "--journal",
179
+ required=True,
180
+ help="Path to journal.jsonl file",
181
+ )
182
+ parser.add_argument(
183
+ "--workdir",
184
+ required=True,
185
+ help="Path to worktree directory",
186
+ )
187
+ parser.add_argument(
188
+ "--json",
189
+ action="store_true",
190
+ help="Output as JSON (default: human-readable)",
191
+ )
192
+
193
+ args = parser.parse_args()
194
+
195
+ # Load and classify
196
+ journal = load_journal(args.journal)
197
+ result = classify_items(journal, args.workdir)
198
+
199
+ # Output
200
+ if args.json:
201
+ print(json.dumps(result, indent=2))
202
+ else:
203
+ print(f"Completed: {len(result['completed'])} items")
204
+ print(f"Remaining: {len(result['remaining'])} items")
205
+ if result["completed"]:
206
+ print(f" Completed: {', '.join(result['completed'])}")
207
+ if result["remaining"]:
208
+ print(f" Remaining: {', '.join(result['remaining'])}")
209
+ print(result["resume_hint"])
210
+
211
+ return 0
212
+
213
+
214
+ if __name__ == "__main__":
215
+ sys.exit(main())
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env python3
2
+ """Wave template manager: load and instantiate preset manifests for common project types.
3
+
4
+ Provides reusable wave-manifest scaffolds for bootstrapping first waves:
5
+ - SaaS: API + frontend + ops (typical 3-tier)
6
+ - Data: pipeline + analytics + infra (ETL/analytics pattern)
7
+ - Library: core + tests + docs (reusable module pattern)
8
+
9
+ Each preset is a JSON file with template variables ({project_name}, {base_dir})
10
+ that are substituted during instantiation.
11
+
12
+ Usage (CLI):
13
+ python tools/wave_templates.py <preset> --project-name my-app --base-dir /workspace
14
+
15
+ Usage (Python API):
16
+ from wave_templates import load_preset, instantiate_template
17
+ preset = load_preset("saas")
18
+ manifest = instantiate_template(preset, project_name="my-app", base_dir="/workspace")
19
+
20
+ Invariants:
21
+ - Presets live in templates/wave-presets/ (git-tracked, editable)
22
+ - No file ownership overlap within a manifest (preflight guard)
23
+ - All paths use forward slashes (POSIX) for cross-platform compatibility
24
+ - UTF-8 encoding, LF line endings (Linux parity)
25
+ - Manifests are fully resolved JSON (no further substitution by the wave engine)
26
+ """
27
+
28
+ import argparse
29
+ import json
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Dict, Any, List, Optional, Tuple
33
+
34
+ # Presets directory (relative to this file's location).
35
+ TOOLS_DIR = Path(__file__).resolve().parent
36
+ REPO_ROOT = TOOLS_DIR.parent
37
+ PRESETS_DIR = REPO_ROOT / "templates" / "wave-presets"
38
+
39
+
40
+ def load_preset(preset_name: str) -> Dict[str, Any]:
41
+ """Load a preset manifest template by name.
42
+
43
+ Args:
44
+ preset_name: preset identifier (e.g., "saas", "data", "library")
45
+
46
+ Returns:
47
+ dict with preset template (contains {project_name}, {base_dir} placeholders)
48
+
49
+ Raises:
50
+ FileNotFoundError: if preset file does not exist
51
+ json.JSONDecodeError: if preset JSON is invalid
52
+ """
53
+ preset_file = PRESETS_DIR / f"{preset_name}.json"
54
+ if not preset_file.exists():
55
+ raise FileNotFoundError(f"Preset not found: {preset_file}")
56
+
57
+ with open(preset_file, "r", encoding="utf-8") as f:
58
+ return json.load(f)
59
+
60
+
61
+ def instantiate_template(
62
+ preset: Dict[str, Any],
63
+ project_name: str,
64
+ base_dir: str,
65
+ ) -> Dict[str, Any]:
66
+ """Instantiate a preset template with project-specific values.
67
+
68
+ Replaces placeholder strings {project_name} and {base_dir} throughout
69
+ the preset. Also derives a wave_id and wave_description if not provided.
70
+
71
+ Args:
72
+ preset: template dict from load_preset()
73
+ project_name: user's project/app name (e.g., "payment-api")
74
+ base_dir: root working directory (e.g., "/workspace/my-app")
75
+
76
+ Returns:
77
+ dict with fully resolved manifest (ready to pass to wave engine)
78
+ """
79
+ # Deep copy to avoid mutating the original preset.
80
+ manifest = json.loads(json.dumps(preset))
81
+
82
+ # Utility to substitute placeholders recursively.
83
+ def substitute(obj):
84
+ if isinstance(obj, str):
85
+ return obj.replace("{project_name}", project_name).replace("{base_dir}", base_dir)
86
+ elif isinstance(obj, dict):
87
+ return {k: substitute(v) for k, v in obj.items()}
88
+ elif isinstance(obj, list):
89
+ return [substitute(item) for item in obj]
90
+ else:
91
+ return obj
92
+
93
+ manifest = substitute(manifest)
94
+
95
+ # Add wave metadata if not already present.
96
+ if "wave_id" not in manifest:
97
+ manifest["wave_id"] = f"wave-{project_name}".lower().replace(" ", "-")
98
+ if "wave_description" not in manifest:
99
+ manifest["wave_description"] = f"Bootstrap wave for {project_name}"
100
+
101
+ return manifest
102
+
103
+
104
+ def validate_manifest(
105
+ manifest: Dict[str, Any],
106
+ allow_placeholders: bool = True,
107
+ require_testcmd: bool = True
108
+ ) -> None:
109
+ """Validate manifest schema and invariants.
110
+
111
+ Checks:
112
+ - items array exists and is non-empty
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)
116
+
117
+ Args:
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.
124
+
125
+ Raises:
126
+ ValueError: if validation fails with detailed error per item
127
+ """
128
+ if "items" not in manifest:
129
+ raise ValueError("Manifest missing 'items' array")
130
+
131
+ items = manifest["items"]
132
+ if not isinstance(items, list) or len(items) == 0:
133
+ raise ValueError("'items' must be a non-empty list")
134
+
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
+
141
+ owner_map = {}
142
+ conflicts = []
143
+ errors = []
144
+
145
+ for item_idx, item in enumerate(items):
146
+ if not isinstance(item, dict):
147
+ errors.append(f"Item {item_idx}: must be a dict, got {type(item)}")
148
+ continue
149
+
150
+ # Check required core fields.
151
+ for field in required_core_fields:
152
+ if field not in item:
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'")
158
+
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}")
163
+
164
+ # Check ownsFiles.
165
+ owned_files = item.get("ownsFiles", [])
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).
174
+ for f in owned_files:
175
+ if f in owner_map:
176
+ conflicts.append((f, owner_map[f], slug))
177
+ else:
178
+ owner_map[f] = slug
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
+
185
+ if 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
+
197
+
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
241
+
242
+
243
+ def main():
244
+ """CLI entry point: load preset, instantiate, validate, or validate presets."""
245
+ parser = argparse.ArgumentParser(
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"
273
+ )
274
+ inst_parser.add_argument(
275
+ "preset",
276
+ help="preset name: saas, data, or library"
277
+ )
278
+ inst_parser.add_argument(
279
+ "--project-name",
280
+ required=True,
281
+ help="project/app name (e.g., 'payment-api')"
282
+ )
283
+ inst_parser.add_argument(
284
+ "--base-dir",
285
+ required=True,
286
+ help="base working directory (e.g., '/workspace/my-app')"
287
+ )
288
+ inst_parser.add_argument(
289
+ "--output",
290
+ default=None,
291
+ help="output file (default: stdout)"
292
+ )
293
+
294
+ args = parser.parse_args()
295
+
296
+ try:
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
+
329
+ else:
330
+ # No command specified - print help
331
+ parser.print_help()
332
+ sys.exit(0)
333
+
334
+ except Exception as e:
335
+ print(f"Error: {e}", file=sys.stderr)
336
+ sys.exit(1)
337
+
338
+
339
+ if __name__ == "__main__":
340
+ main()
package/ui/agents.py CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  """Aesop UI — agent transcript reading + path-traversal-safe id handling (wave-9 split)."""
3
3
  import json
4
+ import os
4
5
  import re
5
6
  import subprocess
6
7
  import sys
8
+ import time
7
9
  from pathlib import Path
8
10
 
9
11
  import config
@@ -59,6 +61,11 @@ def sanitize_agents_for_broadcast(agents):
59
61
  def get_fleet_agents():
60
62
  """Detect running subagents by calling dash-extra.mjs --json.
61
63
 
64
+ Fallback (test-only): when AESOP_PROOF_FIXTURES=1 is set, read fixture agents from
65
+ _collector.json if dash-extra.mjs returns no agents (used by browser proofs that
66
+ don't have running agent processes). This env var is NEVER set in production, so
67
+ the fallback is safely gated.
68
+
62
69
  dash-extra.mjs truncates agent ids to 13 characters for display. With enough
63
70
  concurrently-active agents, two distinct agents can share the same 13-char
64
71
  prefix and collide onto the same id. The dashboard keys DOM rows (and the
@@ -71,23 +78,36 @@ def get_fleet_agents():
71
78
  try:
72
79
  # Call the working detector (dash-extra.mjs) with --json flag
73
80
  dash_extra_path = config.AESOP_ROOT / "dash" / "dash-extra.mjs"
74
- if not dash_extra_path.exists():
75
- return agents
76
- result = subprocess.run(
77
- ["node", str(dash_extra_path), "--json"],
78
- capture_output=True,
79
- text=True,
80
- encoding='utf-8',
81
- errors='replace',
82
- timeout=5
83
- )
84
- if result.returncode == 0 and result.stdout:
85
- agents = json.loads(result.stdout.strip())
81
+ if dash_extra_path.exists():
82
+ result = subprocess.run(
83
+ ["node", str(dash_extra_path), "--json"],
84
+ capture_output=True,
85
+ text=True,
86
+ encoding='utf-8',
87
+ errors='replace',
88
+ timeout=5
89
+ )
90
+ if result.returncode == 0 and result.stdout:
91
+ agents = json.loads(result.stdout.strip())
86
92
  except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
87
93
  pass
88
94
  except Exception:
89
95
  pass
90
96
 
97
+ # Fallback (test-only): if no agents detected by dash-extra.mjs AND
98
+ # AESOP_PROOF_FIXTURES is set, try reading from _collector.json
99
+ # (used by browser proofs that create fixture agents in state/_collector.json).
100
+ # This is gated by an explicit env var so it never fires in production.
101
+ if not agents and os.getenv("AESOP_PROOF_FIXTURES"):
102
+ try:
103
+ collector_json = config.STATE_DIR / "_collector.json"
104
+ if collector_json.exists():
105
+ with open(collector_json, encoding='utf-8') as f:
106
+ data = json.load(f)
107
+ agents = data.get("agents", [])
108
+ except (json.JSONDecodeError, OSError, KeyError):
109
+ pass
110
+
91
111
  seen = {}
92
112
  for a in agents:
93
113
  if not isinstance(a, dict):
@@ -102,6 +122,12 @@ def get_fleet_agents():
102
122
 
103
123
  _AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
104
124
 
125
+ # Fingerprint caching: prevent expensive recursive glob on every collector tick (~1Hz).
126
+ # Cache the fingerprint for N seconds, recompute only when window expires.
127
+ # This keeps the cache-busting purpose (detect real changes), but throttles the cost.
128
+ _FINGERPRINT_CACHE = {"value": None, "expires": 0.0}
129
+ _FINGERPRINT_CACHE_TTL = 5.0 # seconds; can be overridden by tests
130
+
105
131
  # Transcript-tail bounds (defense against loading a whole multi-MB transcript
106
132
  # into memory and against emitting an unbounded payload to the browser).
107
133
  TRANSCRIPT_TAIL_LINES = 40 # last N NDJSON lines rendered in the drawer
@@ -457,12 +483,15 @@ def extract_agent_dispatch_prompt(agent_id):
457
483
  print(f"[extract_agent_dispatch_prompt] Uncaught exception: {e}", file=sys.stderr)
458
484
  return {"error": "Failed to extract dispatch prompt"}
459
485
 
460
- def _transcripts_fingerprint():
461
- """Cheap fs-stat-only fingerprint of the transcripts tree.
486
+ def _transcripts_fingerprint_uncached():
487
+ """Cheap fs-stat-only fingerprint of the transcripts tree (no caching).
462
488
 
463
489
  Used to decide whether it's worth re-invoking `node dash-extra.mjs` (which is
464
490
  comparatively expensive: process spawn + re-parsing every agent transcript).
465
491
  Only file count + max mtime — no file content is read.
492
+
493
+ This is the raw implementation; see _transcripts_fingerprint() for the
494
+ cache-throttled wrapper.
466
495
  """
467
496
  try:
468
497
  if not config.TRANSCRIPTS_ROOT.exists():
@@ -480,3 +509,28 @@ def _transcripts_fingerprint():
480
509
  return (count, latest)
481
510
  except Exception:
482
511
  return (0, 0.0)
512
+
513
+
514
+ def _transcripts_fingerprint():
515
+ """Cached wrapper around _transcripts_fingerprint_uncached().
516
+
517
+ Throttles fingerprint recomputation to at most once per _FINGERPRINT_CACHE_TTL
518
+ seconds (default 5s, configurable). Returns cached value until window expires.
519
+ This keeps the cache-busting purpose (detect real agent changes) but prevents
520
+ the expensive recursive glob from running on every collector tick (~1Hz).
521
+
522
+ Returns:
523
+ tuple: (file_count, latest_mtime) — same format as uncached version.
524
+ """
525
+ global _FINGERPRINT_CACHE
526
+ now = time.time()
527
+
528
+ # If cache is still valid, return cached value
529
+ if _FINGERPRINT_CACHE["value"] is not None and now < _FINGERPRINT_CACHE["expires"]:
530
+ return _FINGERPRINT_CACHE["value"]
531
+
532
+ # Cache expired or uninitialized; recompute
533
+ value = _transcripts_fingerprint_uncached()
534
+ _FINGERPRINT_CACHE["value"] = value
535
+ _FINGERPRINT_CACHE["expires"] = now + _FINGERPRINT_CACHE_TTL
536
+ return value