@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.
- package/CHANGELOG.md +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- 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,215 @@
|
|
|
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
|
|
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(manifest: Dict[str, Any]) -> None:
|
|
105
|
+
"""Validate manifest schema and invariants.
|
|
106
|
+
|
|
107
|
+
Checks:
|
|
108
|
+
- 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
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
manifest: the manifest dict to validate
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ValueError: if validation fails
|
|
118
|
+
"""
|
|
119
|
+
if "items" not in manifest:
|
|
120
|
+
raise ValueError("Manifest missing 'items' array")
|
|
121
|
+
|
|
122
|
+
items = manifest["items"]
|
|
123
|
+
if not isinstance(items, list) or len(items) == 0:
|
|
124
|
+
raise ValueError("'items' must be a non-empty list")
|
|
125
|
+
|
|
126
|
+
# Check required fields and validate structure.
|
|
127
|
+
required_fields = {"slug", "prompt", "ownsFiles"}
|
|
128
|
+
owner_map = {}
|
|
129
|
+
conflicts = []
|
|
130
|
+
|
|
131
|
+
for item in items:
|
|
132
|
+
if not isinstance(item, dict):
|
|
133
|
+
raise ValueError(f"Item must be a dict, got {type(item)}")
|
|
134
|
+
|
|
135
|
+
# Check required fields.
|
|
136
|
+
for field in required_fields:
|
|
137
|
+
if field not in item:
|
|
138
|
+
raise ValueError(f"Item {item.get('slug', 'unknown')} missing '{field}'")
|
|
139
|
+
|
|
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}")
|
|
143
|
+
|
|
144
|
+
# Check ownsFiles.
|
|
145
|
+
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.
|
|
150
|
+
for f in owned_files:
|
|
151
|
+
if f in owner_map:
|
|
152
|
+
conflicts.append((f, owner_map[f], slug))
|
|
153
|
+
else:
|
|
154
|
+
owner_map[f] = slug
|
|
155
|
+
|
|
156
|
+
if conflicts:
|
|
157
|
+
raise ValueError(f"File ownership overlap: {conflicts}")
|
|
158
|
+
|
|
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")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def main():
|
|
166
|
+
"""CLI entry point: load preset and output resolved manifest."""
|
|
167
|
+
parser = argparse.ArgumentParser(
|
|
168
|
+
description="Load and instantiate a wave manifest preset"
|
|
169
|
+
)
|
|
170
|
+
parser.add_argument(
|
|
171
|
+
"preset",
|
|
172
|
+
help="preset name: saas, data, or library"
|
|
173
|
+
)
|
|
174
|
+
parser.add_argument(
|
|
175
|
+
"--project-name",
|
|
176
|
+
required=True,
|
|
177
|
+
help="project/app name (e.g., 'payment-api')"
|
|
178
|
+
)
|
|
179
|
+
parser.add_argument(
|
|
180
|
+
"--base-dir",
|
|
181
|
+
required=True,
|
|
182
|
+
help="base working directory (e.g., '/workspace/my-app')"
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--output",
|
|
186
|
+
default=None,
|
|
187
|
+
help="output file (default: stdout)"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
args = parser.parse_args()
|
|
191
|
+
|
|
192
|
+
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)
|
|
206
|
+
else:
|
|
207
|
+
print(output_json)
|
|
208
|
+
|
|
209
|
+
except Exception as e:
|
|
210
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == "__main__":
|
|
215
|
+
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
package/ui/collectors.py
CHANGED
|
@@ -371,61 +371,6 @@ def save_tracker(tracker):
|
|
|
371
371
|
print(f"[tracker] Failed to unlink temp file: {ue}", file=sys.stderr)
|
|
372
372
|
raise
|
|
373
373
|
|
|
374
|
-
def migrate_tracker_from_backlog():
|
|
375
|
-
"""One-time idempotent migration: AUDIT-BACKLOG.md -> tracker.json."""
|
|
376
|
-
if config.TRACKER_FILE.exists():
|
|
377
|
-
return load_tracker()
|
|
378
|
-
|
|
379
|
-
backlog_data = parse_audit_backlog()
|
|
380
|
-
if not backlog_data.get("tiers"):
|
|
381
|
-
return {"version": 1, "items": []}
|
|
382
|
-
|
|
383
|
-
items = []
|
|
384
|
-
for tier_data in backlog_data["tiers"]:
|
|
385
|
-
priority = tier_data["tier"]
|
|
386
|
-
|
|
387
|
-
for backlog_item in tier_data.get("items", []):
|
|
388
|
-
status_glyph = backlog_item["status"]
|
|
389
|
-
|
|
390
|
-
if status_glyph == "✅":
|
|
391
|
-
status, lane = "done", "done"
|
|
392
|
-
tags = []
|
|
393
|
-
elif status_glyph == "🔵":
|
|
394
|
-
status, lane = "in-progress", "in-progress"
|
|
395
|
-
tags = []
|
|
396
|
-
elif status_glyph == "⏸":
|
|
397
|
-
status, lane = "todo", "proposed"
|
|
398
|
-
tags = ["needs-decision"]
|
|
399
|
-
else:
|
|
400
|
-
status, lane = "todo", "ranked"
|
|
401
|
-
tags = []
|
|
402
|
-
|
|
403
|
-
title = backlog_item.get("title", "")
|
|
404
|
-
tag_prefix = backlog_item.get("tag", "")
|
|
405
|
-
if tag_prefix:
|
|
406
|
-
tag_value = tag_prefix.strip("[]")
|
|
407
|
-
if tag_value and tag_value not in tags:
|
|
408
|
-
tags.insert(0, tag_value)
|
|
409
|
-
|
|
410
|
-
item = {
|
|
411
|
-
"id": secrets.token_hex(6),
|
|
412
|
-
"title": title,
|
|
413
|
-
"priority": priority,
|
|
414
|
-
"status": status,
|
|
415
|
-
"lane": lane,
|
|
416
|
-
"source": "audit-backlog-migration",
|
|
417
|
-
"tags": tags,
|
|
418
|
-
"notes": None,
|
|
419
|
-
"pr_link": None,
|
|
420
|
-
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
|
421
|
-
"completed_at": None
|
|
422
|
-
}
|
|
423
|
-
items.append(item)
|
|
424
|
-
|
|
425
|
-
tracker = {"version": 1, "items": items}
|
|
426
|
-
save_tracker(tracker)
|
|
427
|
-
return tracker
|
|
428
|
-
|
|
429
374
|
def get_tracker_items(status=None, priority=None):
|
|
430
375
|
"""Retrieve tracker items with optional filters."""
|
|
431
376
|
tracker = load_tracker()
|
package/ui/config.py
CHANGED
|
@@ -97,8 +97,13 @@ def reload():
|
|
|
97
97
|
ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
|
|
98
98
|
|
|
99
99
|
# Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
|
|
100
|
-
# WEB_DIST:
|
|
101
|
-
WEB_DIST =
|
|
100
|
+
# WEB_DIST: env AESOP_WEB_DIST > AESOP_ROOT/ui/web/dist (test override for fixtures).
|
|
101
|
+
WEB_DIST = Path(
|
|
102
|
+
os.getenv(
|
|
103
|
+
"AESOP_WEB_DIST",
|
|
104
|
+
str(AESOP_ROOT / "ui" / "web" / "dist")
|
|
105
|
+
)
|
|
106
|
+
)
|
|
102
107
|
# LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
|
|
103
108
|
LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
|
|
104
109
|
|