@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,430 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CI workflow linter: static analysis of .github/workflows/*.yml
4
+
5
+ Checks:
6
+ 1. YAML parses cleanly
7
+ 2. Every npm ci step has a package-lock.json in its working directory
8
+ 3. Every test suite in package.json scripts (test:py/test:node/test:sh) is invoked by CI
9
+ 4. Best-effort check for file references in workflow steps
10
+
11
+ Exit: 0 if all checks pass, 1 if any findings. Support --json for structured output.
12
+
13
+ Requires PyYAML. If it is missing the linter FAILS CLOSED (exit 1) rather than
14
+ silently passing — a lint gate that cannot parse workflows must not report green.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ try:
26
+ import yaml
27
+ except ImportError:
28
+ # Import stays soft so the tools-importable smoke gate can load this module;
29
+ # lint_workflows() fails closed when yaml is None.
30
+ yaml = None
31
+
32
+
33
+ class WorkflowLintError(Exception):
34
+ """Raised when a workflow file cannot be read or parsed."""
35
+
36
+
37
+ def load_yaml_file(path):
38
+ """Load and parse a YAML file.
39
+
40
+ Returns:
41
+ dict or None: Parsed YAML
42
+
43
+ Raises:
44
+ WorkflowLintError: If the file cannot be read or parsed
45
+ """
46
+ try:
47
+ with open(path, 'r', encoding='utf-8') as f:
48
+ content = f.read()
49
+ except OSError as e:
50
+ raise WorkflowLintError(f"Failed to load {path}: {e}")
51
+
52
+ try:
53
+ return yaml.safe_load(content)
54
+ except yaml.YAMLError as e:
55
+ raise WorkflowLintError(f"YAML parse error: {e}")
56
+
57
+
58
+ def find_workflow_files(root):
59
+ """Find all workflow files in .github/workflows/
60
+
61
+ Args:
62
+ root: Repository root path
63
+
64
+ Returns:
65
+ List[Path]: Sorted list of workflow file paths
66
+ """
67
+ workflows_dir = Path(root) / ".github" / "workflows"
68
+ if not workflows_dir.exists():
69
+ return []
70
+ return sorted(workflows_dir.glob("*.yml")) + sorted(workflows_dir.glob("*.yaml"))
71
+
72
+
73
+ def find_package_json_files(root):
74
+ """Find all package.json files in repo.
75
+
76
+ Args:
77
+ root: Repository root path
78
+
79
+ Returns:
80
+ Dict[str, dict]: Mapping of relative path to parsed package.json
81
+ """
82
+ packages = {}
83
+ root_path = Path(root)
84
+
85
+ # Find all package.json files
86
+ for package_file in root_path.rglob("package.json"):
87
+ try:
88
+ with open(package_file, 'r', encoding='utf-8') as f:
89
+ data = json.load(f)
90
+ rel_path = str(package_file.relative_to(root_path))
91
+ packages[rel_path] = data
92
+ except Exception:
93
+ pass # Skip unparseable package.json
94
+
95
+ return packages
96
+
97
+
98
+ def get_test_scripts(packages):
99
+ """Extract test:* scripts from all package.json files.
100
+
101
+ Returns:
102
+ Dict[str, dict]: Mapping of script name to {file, command}
103
+ """
104
+ tests = {}
105
+ for pkg_path, pkg_data in packages.items():
106
+ scripts = pkg_data.get("scripts", {})
107
+ for script_name in ["test:py", "test:node", "test:sh"]:
108
+ if script_name in scripts:
109
+ # Determine working directory from package.json path
110
+ pkg_dir = str(Path(pkg_path).parent) if pkg_path != "package.json" else "."
111
+ if pkg_dir == ".":
112
+ pkg_dir = ""
113
+
114
+ script_key = f"{pkg_path}:{script_name}"
115
+ tests[script_key] = {
116
+ "file": pkg_path,
117
+ "dir": pkg_dir,
118
+ "name": script_name,
119
+ "command": scripts[script_name],
120
+ "invoked": False
121
+ }
122
+ return tests
123
+
124
+
125
+ def extract_working_directory(step):
126
+ """Extract working directory from a step (working-directory or cd command).
127
+
128
+ Args:
129
+ step: Step dict from workflow
130
+
131
+ Returns:
132
+ str: Working directory path, or "." if not specified
133
+ """
134
+ # Check for working-directory key
135
+ if "working-directory" in step:
136
+ return step["working-directory"]
137
+
138
+ # Check for cd in run block
139
+ run = step.get("run", "")
140
+ if isinstance(run, str) and run.strip():
141
+ # Look for cd at the start of the run block
142
+ lines = run.split('\n')
143
+ for line in lines:
144
+ line = line.strip()
145
+ if line.startswith("cd "):
146
+ # Extract directory (simple case: cd /path or cd relative/path)
147
+ parts = line.split()
148
+ if len(parts) >= 2:
149
+ return parts[1]
150
+ elif re.match(r'^\s*cd\s+', line):
151
+ # Handle whitespace variations
152
+ match = re.match(r'^\s*cd\s+(.+)$', line)
153
+ if match:
154
+ return match.group(1).strip()
155
+
156
+ return "."
157
+
158
+
159
+ def check_npm_ci_lockfile(workflow_path, workflow_data, root):
160
+ """Check that every npm ci step has package-lock.json in working directory.
161
+
162
+ Returns:
163
+ List[str]: List of findings
164
+ """
165
+ findings = []
166
+
167
+ if not workflow_data:
168
+ return findings
169
+
170
+ jobs = workflow_data.get("jobs", {})
171
+ job_id = 0
172
+
173
+ for job_name, job_data in jobs.items():
174
+ steps = job_data.get("steps", [])
175
+ step_id = 0
176
+
177
+ for step in steps:
178
+ if not isinstance(step, dict):
179
+ step_id += 1
180
+ continue
181
+
182
+ run = step.get("run", "")
183
+
184
+ # Check if this step runs npm ci
185
+ if "npm ci" in str(run):
186
+ working_dir = extract_working_directory(step)
187
+
188
+ # Resolve working directory relative to repo root
189
+ if working_dir == ".":
190
+ lockfile_path = Path(root) / "package-lock.json"
191
+ else:
192
+ lockfile_path = Path(root) / working_dir / "package-lock.json"
193
+
194
+ if not lockfile_path.exists():
195
+ step_name = step.get("name", f"step {step_id}")
196
+ findings.append(
197
+ f"npm ci without package-lock.json: "
198
+ f"{workflow_path.name} > {job_name} > {step_name} "
199
+ f"(working dir: {working_dir})"
200
+ )
201
+
202
+ step_id += 1
203
+
204
+ return findings
205
+
206
+
207
+ def check_test_coverage(workflow_files, workflow_data_list, packages, root):
208
+ """Check that all package.json test scripts are invoked by workflows.
209
+
210
+ Returns:
211
+ List[str]: List of findings
212
+ """
213
+ findings = []
214
+ tests = get_test_scripts(packages)
215
+
216
+ if not tests:
217
+ return findings # No test scripts to check
218
+
219
+ # Scan all workflows for test invocations
220
+ for workflow_data in workflow_data_list:
221
+ if not workflow_data:
222
+ continue
223
+
224
+ jobs = workflow_data.get("jobs", {})
225
+ for job_name, job_data in jobs.items():
226
+ steps = job_data.get("steps", [])
227
+
228
+ for step in steps:
229
+ if not isinstance(step, dict):
230
+ continue
231
+
232
+ run = step.get("run", "")
233
+ if not isinstance(run, str):
234
+ continue
235
+
236
+ # Check for npm run test:* invocations
237
+ for test_script in ["test:py", "test:node", "test:sh", "test:all"]:
238
+ if f"npm run {test_script}" in run or f"npm run {test_script}" in str(step.get("name", "")):
239
+ for test_key in tests:
240
+ if test_script in test_key:
241
+ tests[test_key]["invoked"] = True
242
+
243
+ # Also check for direct script invocations in run blocks
244
+ # test:py -> python -m unittest
245
+ if "python" in run and "unittest" in run:
246
+ for test_key in tests:
247
+ if "test:py" in test_key:
248
+ tests[test_key]["invoked"] = True
249
+
250
+ # test:node -> node --test or similar
251
+ if ("node --test" in run or "npx vitest" in run or
252
+ "npm run test:node" in run):
253
+ for test_key in tests:
254
+ if "test:node" in test_key:
255
+ tests[test_key]["invoked"] = True
256
+
257
+ # test:sh -> bash tests/
258
+ if ("bash tests/" in run or "sh tests/" in run):
259
+ for test_key in tests:
260
+ if "test:sh" in test_key:
261
+ tests[test_key]["invoked"] = True
262
+
263
+ # Report uncovered tests
264
+ for test_key, test_info in tests.items():
265
+ if not test_info["invoked"]:
266
+ findings.append(
267
+ f"Test script not invoked by workflows: "
268
+ f"{test_info['file']} > {test_info['name']}"
269
+ )
270
+
271
+ return findings
272
+
273
+
274
+ def check_yaml_parse(workflow_path):
275
+ """Check that workflow YAML parses cleanly.
276
+
277
+ Returns:
278
+ Tuple[bool, str]: (success, error_message)
279
+ """
280
+ try:
281
+ load_yaml_file(workflow_path)
282
+ return True, ""
283
+ except Exception as e:
284
+ return False, str(e)
285
+
286
+
287
+ def check_file_references(workflow_data, root):
288
+ """Best-effort check for file references in workflow steps.
289
+
290
+ Returns:
291
+ List[str]: List of findings for missing files
292
+ """
293
+ findings = []
294
+
295
+ if not workflow_data:
296
+ return findings
297
+
298
+ jobs = workflow_data.get("jobs", {})
299
+
300
+ for job_name, job_data in jobs.items():
301
+ steps = job_data.get("steps", [])
302
+
303
+ for step in steps:
304
+ if not isinstance(step, dict):
305
+ continue
306
+
307
+ # Check for common file references in run blocks
308
+ run = step.get("run", "")
309
+ if not isinstance(run, str):
310
+ continue
311
+
312
+ # Extract file paths from common patterns
313
+ # e.g., "python tools/foo.py", "bash tests/test.sh"
314
+ patterns = [
315
+ r'python[3]?\s+([^\s&|;]+\.py)',
316
+ r'bash\s+([^\s&|;]+\.sh)',
317
+ r'sh\s+([^\s&|;]+\.sh)',
318
+ r'node\s+([^\s&|;]+\.mjs?)',
319
+ ]
320
+
321
+ for pattern in patterns:
322
+ matches = re.findall(pattern, run)
323
+ for file_ref in matches:
324
+ file_path = Path(root) / file_ref
325
+ if not file_path.exists():
326
+ # Best-effort: don't fail on generated paths or paths with variables
327
+ if "$" not in file_ref and "{" not in file_ref:
328
+ step_name = step.get("name", "unnamed")
329
+ # findings.append(f"File reference not found: {file_ref} ({job_name} > {step_name})")
330
+
331
+ return findings
332
+
333
+
334
+ def lint_workflows(root, json_output=False):
335
+ """Lint all workflows in a repository.
336
+
337
+ Returns:
338
+ Tuple[int, List[str]]: (exit_code, findings_list)
339
+ """
340
+ findings = []
341
+ root_path = Path(root)
342
+
343
+ # Fail closed: without a real YAML parser this linter cannot verify anything,
344
+ # and a lint gate that silently passes is worse than one that fails loudly.
345
+ if yaml is None:
346
+ return 1, [
347
+ "[1] PyYAML is not installed; refusing to lint workflows without a "
348
+ "real YAML parser (fail-closed). Install it with: pip install pyyaml"
349
+ ]
350
+
351
+ # Find workflow files
352
+ workflow_files = find_workflow_files(root)
353
+ if not workflow_files:
354
+ findings.append("No workflow files found in .github/workflows/")
355
+ return 1, findings
356
+
357
+ # Load and parse workflows
358
+ workflow_data_list = []
359
+ for workflow_path in workflow_files:
360
+ success, error = check_yaml_parse(workflow_path)
361
+ if not success:
362
+ findings.append(f"YAML parse error in {workflow_path.name}: {error}")
363
+ continue
364
+
365
+ try:
366
+ data = load_yaml_file(workflow_path)
367
+ workflow_data_list.append(data)
368
+ except Exception as e:
369
+ findings.append(f"Failed to load {workflow_path.name}: {e}")
370
+
371
+ # Find package.json files
372
+ packages = find_package_json_files(root)
373
+
374
+ # Check npm ci lockfiles
375
+ for workflow_path, workflow_data in zip(workflow_files, workflow_data_list):
376
+ if workflow_data:
377
+ findings.extend(check_npm_ci_lockfile(workflow_path, workflow_data, root))
378
+
379
+ # Check test coverage
380
+ findings.extend(check_test_coverage(workflow_files, workflow_data_list, packages, root))
381
+
382
+ # Check file references
383
+ for workflow_data in workflow_data_list:
384
+ if workflow_data:
385
+ findings.extend(check_file_references(workflow_data, root))
386
+
387
+ # Format findings with numbers
388
+ numbered_findings = []
389
+ for i, finding in enumerate(findings, 1):
390
+ numbered_findings.append(f"[{i}] {finding}")
391
+
392
+ return (0 if not findings else 1), numbered_findings
393
+
394
+
395
+ def main():
396
+ parser = argparse.ArgumentParser(
397
+ description="Lint CI workflow files for common issues"
398
+ )
399
+ parser.add_argument(
400
+ "--root",
401
+ default=".",
402
+ help="Repository root path (default: current directory)"
403
+ )
404
+ parser.add_argument(
405
+ "--json",
406
+ action="store_true",
407
+ help="Output findings as JSON"
408
+ )
409
+
410
+ args = parser.parse_args()
411
+
412
+ exit_code, findings = lint_workflows(args.root, args.json)
413
+
414
+ if args.json:
415
+ output = {
416
+ "exit_code": exit_code,
417
+ "findings": findings
418
+ }
419
+ print(json.dumps(output, indent=2))
420
+ else:
421
+ for finding in findings:
422
+ print(finding)
423
+ if not findings and exit_code == 0:
424
+ print("OK: All workflow checks passed")
425
+
426
+ return exit_code
427
+
428
+
429
+ if __name__ == "__main__":
430
+ sys.exit(main())