claude-turing 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.claude-plugin/plugin.json +5 -5
  2. package/LICENSE +1 -1
  3. package/README.md +78 -552
  4. package/bin/cli.js +23 -4
  5. package/commands/doctor.md +31 -0
  6. package/commands/init.md +21 -3
  7. package/commands/plan.md +27 -0
  8. package/commands/postmortem.md +28 -0
  9. package/commands/turing.md +6 -0
  10. package/config/defaults.yaml +2 -0
  11. package/package.json +5 -5
  12. package/src/install.js +18 -2
  13. package/src/verify.js +45 -2
  14. package/templates/README.md +1 -1
  15. package/templates/__pycache__/evaluate.cpython-312.pyc +0 -0
  16. package/templates/__pycache__/prepare.cpython-312.pyc +0 -0
  17. package/templates/config.yaml +1 -1
  18. package/templates/features/__pycache__/__init__.cpython-312.pyc +0 -0
  19. package/templates/features/__pycache__/featurizers.cpython-312.pyc +0 -0
  20. package/templates/program.md +1 -1
  21. package/templates/scripts/__pycache__/__init__.cpython-312.pyc +0 -0
  22. package/templates/scripts/__pycache__/ablation_study.cpython-312.pyc +0 -0
  23. package/templates/scripts/__pycache__/architecture_surgery.cpython-312.pyc +0 -0
  24. package/templates/scripts/__pycache__/budget_manager.cpython-312.pyc +0 -0
  25. package/templates/scripts/__pycache__/build_ensemble.cpython-312.pyc +0 -0
  26. package/templates/scripts/__pycache__/calibration.cpython-312.pyc +0 -0
  27. package/templates/scripts/__pycache__/check_convergence.cpython-312.pyc +0 -0
  28. package/templates/scripts/__pycache__/checkpoint_manager.cpython-312.pyc +0 -0
  29. package/templates/scripts/__pycache__/citation_manager.cpython-312.pyc +0 -0
  30. package/templates/scripts/__pycache__/cost_frontier.cpython-312.pyc +0 -0
  31. package/templates/scripts/__pycache__/counterfactual_explanation.cpython-312.pyc +0 -0
  32. package/templates/scripts/__pycache__/critique_hypothesis.cpython-312.pyc +0 -0
  33. package/templates/scripts/__pycache__/curriculum_optimizer.cpython-312.pyc +0 -0
  34. package/templates/scripts/__pycache__/diagnose_errors.cpython-312.pyc +0 -0
  35. package/templates/scripts/__pycache__/draft_paper_sections.cpython-312.pyc +0 -0
  36. package/templates/scripts/__pycache__/equivalence_checker.cpython-312.pyc +0 -0
  37. package/templates/scripts/__pycache__/experiment_annotations.cpython-312.pyc +0 -0
  38. package/templates/scripts/__pycache__/experiment_archive.cpython-312.pyc +0 -0
  39. package/templates/scripts/__pycache__/experiment_diff.cpython-312.pyc +0 -0
  40. package/templates/scripts/__pycache__/experiment_index.cpython-312.pyc +0 -0
  41. package/templates/scripts/__pycache__/experiment_queue.cpython-312.pyc +0 -0
  42. package/templates/scripts/__pycache__/experiment_replay.cpython-312.pyc +0 -0
  43. package/templates/scripts/__pycache__/experiment_search.cpython-312.pyc +0 -0
  44. package/templates/scripts/__pycache__/experiment_simulator.cpython-312.pyc +0 -0
  45. package/templates/scripts/__pycache__/experiment_templates.cpython-312.pyc +0 -0
  46. package/templates/scripts/__pycache__/export_card.cpython-312.pyc +0 -0
  47. package/templates/scripts/__pycache__/export_formats.cpython-312.pyc +0 -0
  48. package/templates/scripts/__pycache__/failure_postmortem.cpython-312.pyc +0 -0
  49. package/templates/scripts/__pycache__/failure_postmortem.cpython-314.pyc +0 -0
  50. package/templates/scripts/__pycache__/feature_intelligence.cpython-312.pyc +0 -0
  51. package/templates/scripts/__pycache__/fork_experiment.cpython-312.pyc +0 -0
  52. package/templates/scripts/__pycache__/generate_baselines.cpython-312.pyc +0 -0
  53. package/templates/scripts/__pycache__/generate_brief.cpython-312.pyc +0 -0
  54. package/templates/scripts/__pycache__/generate_brief.cpython-314.pyc +0 -0
  55. package/templates/scripts/__pycache__/generate_changelog.cpython-312.pyc +0 -0
  56. package/templates/scripts/__pycache__/generate_figures.cpython-312.pyc +0 -0
  57. package/templates/scripts/__pycache__/generate_logbook.cpython-312.pyc +0 -0
  58. package/templates/scripts/__pycache__/generate_model_card.cpython-312.pyc +0 -0
  59. package/templates/scripts/__pycache__/generate_onboarding.cpython-312.pyc +0 -0
  60. package/templates/scripts/__pycache__/harness_doctor.cpython-312.pyc +0 -0
  61. package/templates/scripts/__pycache__/harness_doctor.cpython-314.pyc +0 -0
  62. package/templates/scripts/__pycache__/incremental_update.cpython-312.pyc +0 -0
  63. package/templates/scripts/__pycache__/knowledge_transfer.cpython-312.pyc +0 -0
  64. package/templates/scripts/__pycache__/latency_benchmark.cpython-312.pyc +0 -0
  65. package/templates/scripts/__pycache__/leakage_detector.cpython-312.pyc +0 -0
  66. package/templates/scripts/__pycache__/literature_search.cpython-312.pyc +0 -0
  67. package/templates/scripts/__pycache__/log_experiment.cpython-312.pyc +0 -0
  68. package/templates/scripts/__pycache__/manage_hypotheses.cpython-312.pyc +0 -0
  69. package/templates/scripts/__pycache__/methodology_audit.cpython-312.pyc +0 -0
  70. package/templates/scripts/__pycache__/model_distiller.cpython-312.pyc +0 -0
  71. package/templates/scripts/__pycache__/model_lifecycle.cpython-312.pyc +0 -0
  72. package/templates/scripts/__pycache__/model_merger.cpython-312.pyc +0 -0
  73. package/templates/scripts/__pycache__/model_pruning.cpython-312.pyc +0 -0
  74. package/templates/scripts/__pycache__/model_quantization.cpython-312.pyc +0 -0
  75. package/templates/scripts/__pycache__/model_xray.cpython-312.pyc +0 -0
  76. package/templates/scripts/__pycache__/novelty_guard.cpython-312.pyc +0 -0
  77. package/templates/scripts/__pycache__/package_experiments.cpython-312.pyc +0 -0
  78. package/templates/scripts/__pycache__/pareto_frontier.cpython-312.pyc +0 -0
  79. package/templates/scripts/__pycache__/parse_metrics.cpython-312.pyc +0 -0
  80. package/templates/scripts/__pycache__/pipeline_manager.cpython-312.pyc +0 -0
  81. package/templates/scripts/__pycache__/profile_training.cpython-312.pyc +0 -0
  82. package/templates/scripts/__pycache__/regression_gate.cpython-312.pyc +0 -0
  83. package/templates/scripts/__pycache__/reproduce_experiment.cpython-312.pyc +0 -0
  84. package/templates/scripts/__pycache__/research_planner.cpython-312.pyc +0 -0
  85. package/templates/scripts/__pycache__/research_planner.cpython-314.pyc +0 -0
  86. package/templates/scripts/__pycache__/sanity_checks.cpython-312.pyc +0 -0
  87. package/templates/scripts/__pycache__/scaffold.cpython-312.pyc +0 -0
  88. package/templates/scripts/__pycache__/scaffold.cpython-314.pyc +0 -0
  89. package/templates/scripts/__pycache__/scaling_estimator.cpython-312.pyc +0 -0
  90. package/templates/scripts/__pycache__/seed_runner.cpython-312.pyc +0 -0
  91. package/templates/scripts/__pycache__/sensitivity_analysis.cpython-312.pyc +0 -0
  92. package/templates/scripts/__pycache__/session_flashback.cpython-312.pyc +0 -0
  93. package/templates/scripts/__pycache__/show_experiment_tree.cpython-312.pyc +0 -0
  94. package/templates/scripts/__pycache__/show_families.cpython-312.pyc +0 -0
  95. package/templates/scripts/__pycache__/simulate_review.cpython-312.pyc +0 -0
  96. package/templates/scripts/__pycache__/smart_retry.cpython-312.pyc +0 -0
  97. package/templates/scripts/__pycache__/statistical_compare.cpython-312.pyc +0 -0
  98. package/templates/scripts/__pycache__/suggest_next.cpython-312.pyc +0 -0
  99. package/templates/scripts/__pycache__/sweep.cpython-312.pyc +0 -0
  100. package/templates/scripts/__pycache__/synthesize_decision.cpython-312.pyc +0 -0
  101. package/templates/scripts/__pycache__/training_monitor.cpython-312.pyc +0 -0
  102. package/templates/scripts/__pycache__/treequest_suggest.cpython-312.pyc +0 -0
  103. package/templates/scripts/__pycache__/trend_analysis.cpython-312.pyc +0 -0
  104. package/templates/scripts/__pycache__/turing_io.cpython-312.pyc +0 -0
  105. package/templates/scripts/__pycache__/update_state.cpython-312.pyc +0 -0
  106. package/templates/scripts/__pycache__/verify_placeholders.cpython-312.pyc +0 -0
  107. package/templates/scripts/__pycache__/warm_start.cpython-312.pyc +0 -0
  108. package/templates/scripts/__pycache__/whatif_engine.cpython-312.pyc +0 -0
  109. package/templates/scripts/failure_postmortem.py +510 -0
  110. package/templates/scripts/generate_brief.py +61 -0
  111. package/templates/scripts/harness_doctor.py +610 -0
  112. package/templates/scripts/research_planner.py +470 -0
  113. package/templates/scripts/scaffold.py +56 -28
@@ -0,0 +1,610 @@
1
+ #!/usr/bin/env python3
2
+ """Harness self-diagnosis for the autoresearch pipeline.
3
+
4
+ Checks environment health, project integrity, resource availability,
5
+ and git state. Identifies common issues and auto-fixes where safe.
6
+
7
+ Usage:
8
+ python scripts/harness_doctor.py
9
+ python scripts/harness_doctor.py --fix
10
+ python scripts/harness_doctor.py --verbose
11
+ python scripts/harness_doctor.py --json
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import ast
18
+ import json
19
+ import shutil
20
+ import sys
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ import yaml
25
+
26
+ from scripts.turing_io import load_config, load_experiments
27
+
28
+ DEFAULT_LOG_PATH = "experiments/log.jsonl"
29
+ MIN_DISK_MB = 1024 # 1 GB
30
+
31
+ REQUIRED_SCRIPTS = ["train.py", "prepare.py", "evaluate.py"]
32
+ REQUIRED_CONFIG_FIELDS = ["evaluation"]
33
+
34
+ CHECK_CATEGORIES = ["environment", "dependencies", "config", "experiment_log",
35
+ "scripts", "disk_space", "git_state", "claude_hooks"]
36
+
37
+
38
+ # --- Individual Checks ---
39
+
40
+
41
+ def check_environment() -> dict:
42
+ """Check Python environment health."""
43
+ issues = []
44
+ version = sys.version_info
45
+
46
+ if version < (3, 10):
47
+ issues.append(f"Python {version.major}.{version.minor} — recommend 3.10+")
48
+
49
+ # Check if running in a venv
50
+ in_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
51
+
52
+ return {
53
+ "name": "Python environment",
54
+ "status": "PASS" if not issues else "WARN",
55
+ "detail": f"Python {version.major}.{version.minor}.{version.micro}, venv={'active' if in_venv else 'not active'}",
56
+ "issues": issues,
57
+ }
58
+
59
+
60
+ def check_dependencies(required: list[str] | None = None) -> dict:
61
+ """Check that required packages are importable."""
62
+ if required is None:
63
+ required = ["yaml", "numpy", "sklearn", "pandas", "scipy"]
64
+
65
+ missing = []
66
+ for pkg in required:
67
+ try:
68
+ __import__(pkg)
69
+ except ImportError:
70
+ missing.append(pkg)
71
+
72
+ if missing:
73
+ return {
74
+ "name": "Dependencies",
75
+ "status": "FAIL",
76
+ "detail": f"{len(missing)} packages missing: {', '.join(missing)}",
77
+ "issues": [f"Cannot import: {pkg}" for pkg in missing],
78
+ "fix": f"pip install {' '.join(missing)}",
79
+ }
80
+
81
+ return {
82
+ "name": "Dependencies",
83
+ "status": "PASS",
84
+ "detail": f"All {len(required)} packages importable",
85
+ "issues": [],
86
+ }
87
+
88
+
89
+ def check_config(config_path: str = "config.yaml") -> dict:
90
+ """Check config.yaml validity and required fields."""
91
+ path = Path(config_path)
92
+ issues = []
93
+
94
+ if not path.exists():
95
+ return {
96
+ "name": "Config",
97
+ "status": "FAIL",
98
+ "detail": f"{config_path} not found",
99
+ "issues": [f"{config_path} missing"],
100
+ "fix": "Run /turing:init to scaffold the project",
101
+ }
102
+
103
+ try:
104
+ with open(path) as f:
105
+ config = yaml.safe_load(f)
106
+ except yaml.YAMLError as e:
107
+ return {
108
+ "name": "Config",
109
+ "status": "FAIL",
110
+ "detail": f"{config_path} has YAML parse error",
111
+ "issues": [str(e)],
112
+ }
113
+
114
+ if not isinstance(config, dict):
115
+ return {
116
+ "name": "Config",
117
+ "status": "FAIL",
118
+ "detail": f"{config_path} is not a YAML mapping",
119
+ "issues": ["Config must be a YAML dict"],
120
+ }
121
+
122
+ for field in REQUIRED_CONFIG_FIELDS:
123
+ if field not in config:
124
+ issues.append(f"Missing required field: {field}")
125
+
126
+ status = "PASS" if not issues else "WARN"
127
+ return {
128
+ "name": "Config",
129
+ "status": status,
130
+ "detail": f"{config_path} valid, {len(config)} top-level keys",
131
+ "issues": issues,
132
+ }
133
+
134
+
135
+ def check_experiment_log(log_path: str = DEFAULT_LOG_PATH) -> dict:
136
+ """Check experiment log integrity."""
137
+ path = Path(log_path)
138
+
139
+ if not path.exists():
140
+ return {
141
+ "name": "Experiment log",
142
+ "status": "WARN",
143
+ "detail": "No experiment log yet — run /turing:train first",
144
+ "issues": [],
145
+ }
146
+
147
+ issues = []
148
+ total_lines = 0
149
+ valid_lines = 0
150
+ corrupt_lines = []
151
+ missing_fields = []
152
+
153
+ with open(path) as f:
154
+ for i, line in enumerate(f, 1):
155
+ total_lines += 1
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+ try:
160
+ entry = json.loads(line)
161
+ valid_lines += 1
162
+ # Check for expected fields
163
+ if "metrics" not in entry:
164
+ missing_fields.append(i)
165
+ except json.JSONDecodeError:
166
+ corrupt_lines.append(i)
167
+
168
+ if corrupt_lines:
169
+ issues.append(f"{len(corrupt_lines)} corrupt lines: {corrupt_lines[:5]}")
170
+ if missing_fields:
171
+ issues.append(f"{len(missing_fields)} entries missing 'metrics' field")
172
+
173
+ status = "FAIL" if corrupt_lines else ("WARN" if missing_fields else "PASS")
174
+ return {
175
+ "name": "Experiment log",
176
+ "status": status,
177
+ "detail": f"{valid_lines}/{total_lines} valid entries",
178
+ "issues": issues,
179
+ "corrupt_lines": corrupt_lines,
180
+ "fixable": len(corrupt_lines) > 0,
181
+ }
182
+
183
+
184
+ def check_scripts(script_dir: str = ".") -> dict:
185
+ """Check that required scripts exist and are syntactically valid."""
186
+ issues = []
187
+ checked = 0
188
+
189
+ for script in REQUIRED_SCRIPTS:
190
+ path = Path(script_dir) / script
191
+ if not path.exists():
192
+ issues.append(f"{script} not found")
193
+ continue
194
+
195
+ try:
196
+ source = path.read_text(encoding="utf-8")
197
+ ast.parse(source, filename=script)
198
+ checked += 1
199
+ except SyntaxError as e:
200
+ issues.append(f"{script} has syntax error: {e.msg} (line {e.lineno})")
201
+
202
+ status = "PASS" if not issues else ("WARN" if checked > 0 else "FAIL")
203
+ return {
204
+ "name": "Scripts",
205
+ "status": status,
206
+ "detail": f"{checked}/{len(REQUIRED_SCRIPTS)} scripts valid",
207
+ "issues": issues,
208
+ }
209
+
210
+
211
+ def check_disk_space(project_dir: str = ".", min_mb: int = MIN_DISK_MB) -> dict:
212
+ """Check available disk space."""
213
+ try:
214
+ usage = shutil.disk_usage(project_dir)
215
+ free_mb = usage.free / (1024 * 1024)
216
+ total_mb = usage.total / (1024 * 1024)
217
+
218
+ if free_mb < min_mb:
219
+ return {
220
+ "name": "Disk space",
221
+ "status": "FAIL",
222
+ "detail": f"{free_mb:.0f} MB remaining — below {min_mb} MB threshold",
223
+ "issues": [f"Low disk space: {free_mb:.0f} MB free of {total_mb:.0f} MB"],
224
+ "fix": "Run /turing:archive to reclaim space",
225
+ "free_mb": round(free_mb),
226
+ }
227
+
228
+ return {
229
+ "name": "Disk space",
230
+ "status": "PASS",
231
+ "detail": f"{free_mb:.0f} MB free",
232
+ "issues": [],
233
+ "free_mb": round(free_mb),
234
+ }
235
+ except OSError as e:
236
+ return {
237
+ "name": "Disk space",
238
+ "status": "WARN",
239
+ "detail": f"Could not check disk: {e}",
240
+ "issues": [str(e)],
241
+ }
242
+
243
+
244
+ def check_git_state(project_dir: str = ".") -> dict:
245
+ """Check git working tree state."""
246
+ import subprocess
247
+
248
+ try:
249
+ result = subprocess.run(
250
+ ["git", "status", "--porcelain"],
251
+ capture_output=True, text=True, timeout=10,
252
+ cwd=project_dir,
253
+ )
254
+ if result.returncode != 0:
255
+ return {
256
+ "name": "Git state",
257
+ "status": "WARN",
258
+ "detail": "Not a git repository or git not available",
259
+ "issues": [],
260
+ }
261
+
262
+ modified = result.stdout.strip().split("\n") if result.stdout.strip() else []
263
+ issues = []
264
+
265
+ # Check if critical files are modified
266
+ critical = {"evaluate.py", "prepare.py"}
267
+ for line in modified:
268
+ if len(line) >= 3:
269
+ filepath = line[3:].strip()
270
+ if any(c in filepath for c in critical):
271
+ issues.append(f"Uncommitted changes to {filepath} — evaluation integrity at risk")
272
+
273
+ status = "WARN" if issues else "PASS"
274
+ detail = "Working tree clean" if not modified else f"{len(modified)} modified files"
275
+
276
+ return {
277
+ "name": "Git state",
278
+ "status": status,
279
+ "detail": detail,
280
+ "issues": issues,
281
+ }
282
+ except (subprocess.TimeoutExpired, FileNotFoundError):
283
+ return {
284
+ "name": "Git state",
285
+ "status": "WARN",
286
+ "detail": "Git check skipped (timeout or not available)",
287
+ "issues": [],
288
+ }
289
+
290
+
291
+ def check_claude_hooks(settings_path: str = ".claude/settings.local.json") -> dict:
292
+ """Check Claude Code project hook schema."""
293
+ path = Path(settings_path)
294
+ if not path.exists():
295
+ return {
296
+ "name": "Claude hooks",
297
+ "status": "WARN",
298
+ "detail": f"{settings_path} not found",
299
+ "issues": [],
300
+ }
301
+
302
+ try:
303
+ settings = json.loads(path.read_text(encoding="utf-8"))
304
+ except json.JSONDecodeError as e:
305
+ return {
306
+ "name": "Claude hooks",
307
+ "status": "FAIL",
308
+ "detail": f"{settings_path} has JSON parse error",
309
+ "issues": [str(e)],
310
+ "fixable": False,
311
+ }
312
+
313
+ hooks = settings.get("hooks", {})
314
+ if not isinstance(hooks, dict):
315
+ return {
316
+ "name": "Claude hooks",
317
+ "status": "FAIL",
318
+ "detail": "hooks must be a JSON object",
319
+ "issues": ["hooks must map event names to hook group arrays"],
320
+ "fixable": False,
321
+ }
322
+
323
+ issues = []
324
+ fixable = False
325
+ group_count = 0
326
+
327
+ for event, entries in hooks.items():
328
+ if not isinstance(entries, list):
329
+ issues.append(f"hooks.{event} must be an array")
330
+ continue
331
+
332
+ for index, entry in enumerate(entries):
333
+ if not isinstance(entry, dict):
334
+ issues.append(f"hooks.{event}[{index}] must be an object")
335
+ continue
336
+
337
+ if (
338
+ set(entry.keys()) == {"type", "command"}
339
+ and entry["type"] == "command"
340
+ and isinstance(entry["command"], str)
341
+ ):
342
+ issues.append(f"hooks.{event}[{index}] uses legacy bare command hook shape")
343
+ fixable = True
344
+ continue
345
+
346
+ nested_hooks = entry.get("hooks")
347
+ if not isinstance(nested_hooks, list):
348
+ issues.append(f"hooks.{event}[{index}].hooks must be an array")
349
+ continue
350
+
351
+ group_count += 1
352
+ for hook_index, hook in enumerate(nested_hooks):
353
+ if not isinstance(hook, dict):
354
+ issues.append(f"hooks.{event}[{index}].hooks[{hook_index}] must be an object")
355
+ continue
356
+ if hook.get("type") == "command" and not hook.get("command"):
357
+ issues.append(f"hooks.{event}[{index}].hooks[{hook_index}] command hook missing command")
358
+
359
+ status = "FAIL" if issues else "PASS"
360
+ result = {
361
+ "name": "Claude hooks",
362
+ "status": status,
363
+ "detail": f"{group_count} hook groups valid" if not issues else f"{len(issues)} hook schema issue(s)",
364
+ "issues": issues,
365
+ "fixable": fixable,
366
+ }
367
+ if fixable:
368
+ result["fix"] = "Run /turing:doctor --fix to migrate legacy bare command hooks"
369
+ return result
370
+
371
+
372
+ # --- Fix Operations ---
373
+
374
+
375
+ def fix_claude_hooks(settings_path: str = ".claude/settings.local.json") -> dict:
376
+ """Migrate legacy bare command hooks into Claude Code hook groups."""
377
+ path = Path(settings_path)
378
+ if not path.exists():
379
+ return {"fixed": False, "reason": "Settings file not found"}
380
+
381
+ try:
382
+ settings = json.loads(path.read_text(encoding="utf-8"))
383
+ except json.JSONDecodeError:
384
+ return {"fixed": False, "reason": "Settings file has invalid JSON"}
385
+
386
+ hooks = settings.get("hooks")
387
+ if not isinstance(hooks, dict):
388
+ return {"fixed": False, "reason": "hooks is not a JSON object"}
389
+
390
+ migrated = 0
391
+ for event, entries in hooks.items():
392
+ if not isinstance(entries, list):
393
+ continue
394
+
395
+ fixed_entries = []
396
+ for entry in entries:
397
+ if (
398
+ isinstance(entry, dict)
399
+ and set(entry.keys()) == {"type", "command"}
400
+ and entry["type"] == "command"
401
+ and isinstance(entry["command"], str)
402
+ ):
403
+ fixed_entries.append({
404
+ "matcher": "",
405
+ "hooks": [{"type": "command", "command": entry["command"]}],
406
+ })
407
+ migrated += 1
408
+ else:
409
+ fixed_entries.append(entry)
410
+ hooks[event] = fixed_entries
411
+
412
+ if not migrated:
413
+ return {"fixed": False, "reason": "No legacy bare command hooks found"}
414
+
415
+ backup = path.with_suffix(path.suffix + ".bak")
416
+ shutil.copy2(path, backup)
417
+ path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
418
+ return {"fixed": True, "migrated": migrated, "backup": str(backup)}
419
+
420
+
421
+ def fix_corrupt_log(log_path: str = DEFAULT_LOG_PATH) -> dict:
422
+ """Remove corrupt lines from experiment log."""
423
+ path = Path(log_path)
424
+ if not path.exists():
425
+ return {"fixed": False, "reason": "Log not found"}
426
+
427
+ valid_lines = []
428
+ removed = 0
429
+
430
+ with open(path) as f:
431
+ for line in f:
432
+ line_stripped = line.strip()
433
+ if not line_stripped:
434
+ continue
435
+ try:
436
+ json.loads(line_stripped)
437
+ valid_lines.append(line)
438
+ except json.JSONDecodeError:
439
+ removed += 1
440
+
441
+ if removed > 0:
442
+ # Backup first
443
+ backup = path.with_suffix(".jsonl.bak")
444
+ shutil.copy2(path, backup)
445
+ with open(path, "w") as f:
446
+ f.writelines(valid_lines)
447
+ return {"fixed": True, "removed": removed, "backup": str(backup)}
448
+
449
+ return {"fixed": False, "reason": "No corrupt lines found"}
450
+
451
+
452
+ # --- Full Doctor ---
453
+
454
+
455
+ def run_doctor(
456
+ config_path: str = "config.yaml",
457
+ log_path: str = DEFAULT_LOG_PATH,
458
+ hooks_path: str = ".claude/settings.local.json",
459
+ fix: bool = False,
460
+ verbose: bool = False,
461
+ ) -> dict:
462
+ """Run all diagnostic checks.
463
+
464
+ Args:
465
+ config_path: Path to config.yaml.
466
+ log_path: Path to experiment log.
467
+ hooks_path: Path to Claude Code project settings.
468
+ fix: If True, auto-fix safe issues.
469
+ verbose: Include detailed info.
470
+
471
+ Returns:
472
+ Doctor report with all check results and score.
473
+ """
474
+ checks = [
475
+ check_environment(),
476
+ check_dependencies(),
477
+ check_config(config_path),
478
+ check_experiment_log(log_path),
479
+ check_scripts(),
480
+ check_disk_space(),
481
+ check_git_state(),
482
+ check_claude_hooks(hooks_path),
483
+ ]
484
+
485
+ # Apply fixes if requested
486
+ fixes_applied = []
487
+ if fix:
488
+ log_check = next((c for c in checks if c["name"] == "Experiment log"), None)
489
+ if log_check and log_check.get("fixable"):
490
+ fix_result = fix_corrupt_log(log_path)
491
+ if fix_result.get("fixed"):
492
+ fixes_applied.append(f"Removed {fix_result['removed']} corrupt log entries (backup: {fix_result['backup']})")
493
+ # Re-run log check
494
+ for i, c in enumerate(checks):
495
+ if c["name"] == "Experiment log":
496
+ checks[i] = check_experiment_log(log_path)
497
+ break
498
+
499
+ hooks_check = next((c for c in checks if c["name"] == "Claude hooks"), None)
500
+ if hooks_check and hooks_check.get("fixable"):
501
+ fix_result = fix_claude_hooks(hooks_path)
502
+ if fix_result.get("fixed"):
503
+ fixes_applied.append(
504
+ f"Migrated {fix_result['migrated']} legacy Claude hook entries (backup: {fix_result['backup']})"
505
+ )
506
+ for i, c in enumerate(checks):
507
+ if c["name"] == "Claude hooks":
508
+ checks[i] = check_claude_hooks(hooks_path)
509
+ break
510
+
511
+ # Compute score
512
+ passed = sum(1 for c in checks if c["status"] == "PASS")
513
+ warned = sum(1 for c in checks if c["status"] == "WARN")
514
+ failed = sum(1 for c in checks if c["status"] == "FAIL")
515
+ total = len(checks)
516
+
517
+ return {
518
+ "checks": checks,
519
+ "score": {"passed": passed, "warned": warned, "failed": failed, "total": total},
520
+ "fixes_applied": fixes_applied,
521
+ "overall": "HEALTHY" if failed == 0 and warned == 0 else ("DEGRADED" if failed == 0 else "UNHEALTHY"),
522
+ "generated_at": datetime.now(timezone.utc).isoformat(),
523
+ }
524
+
525
+
526
+ # --- Report Formatting ---
527
+
528
+
529
+ def save_doctor_report(report: dict, output_dir: str = "experiments/doctor") -> Path:
530
+ """Save doctor report to YAML."""
531
+ out_path = Path(output_dir)
532
+ out_path.mkdir(parents=True, exist_ok=True)
533
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
534
+ filepath = out_path / f"doctor-{ts}.yaml"
535
+ with open(filepath, "w") as f:
536
+ yaml.dump(report, f, default_flow_style=False, sort_keys=False)
537
+ return filepath
538
+
539
+
540
+ def format_doctor_report(report: dict) -> str:
541
+ """Format doctor report as readable text."""
542
+ lines = ["# Turing Doctor Report", ""]
543
+
544
+ status_icons = {"PASS": "✓ PASS ", "WARN": "⚠ WARN ", "FAIL": "✗ FAIL "}
545
+
546
+ for check in report.get("checks", []):
547
+ icon = status_icons.get(check["status"], "? ")
548
+ lines.append(f"{icon} {check['name']} ({check.get('detail', '')})")
549
+ for issue in check.get("issues", []):
550
+ lines.append(f" {issue}")
551
+ fix = check.get("fix")
552
+ if fix:
553
+ lines.append(f" Fix: {fix}")
554
+
555
+ score = report.get("score", {})
556
+ lines.extend([
557
+ "",
558
+ f"Score: {score.get('passed', 0)}/{score.get('total', 0)} pass, "
559
+ f"{score.get('warned', 0)} warning{'s' if score.get('warned', 0) != 1 else ''}, "
560
+ f"{score.get('failed', 0)} failure{'s' if score.get('failed', 0) != 1 else ''}",
561
+ f"Overall: {report.get('overall', 'UNKNOWN')}",
562
+ ])
563
+
564
+ fixes = report.get("fixes_applied", [])
565
+ if fixes:
566
+ lines.extend(["", "Fixes applied:"])
567
+ for f in fixes:
568
+ lines.append(f" - {f}")
569
+
570
+ lines.append("")
571
+ lines.append(f"*Generated: {report.get('generated_at', 'N/A')}*")
572
+ return "\n".join(lines)
573
+
574
+
575
+ # --- CLI ---
576
+
577
+
578
+ def main():
579
+ parser = argparse.ArgumentParser(
580
+ description="Harness self-diagnosis — check environment, project, and resource health"
581
+ )
582
+ parser.add_argument("--fix", action="store_true", help="Auto-fix safe issues")
583
+ parser.add_argument("--verbose", action="store_true", help="Show detailed info")
584
+ parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
585
+ parser.add_argument("--log", default=DEFAULT_LOG_PATH, help="Path to experiment log")
586
+ parser.add_argument("--hooks", default=".claude/settings.local.json", help="Path to Claude Code settings")
587
+ parser.add_argument("--json", action="store_true", help="Output raw JSON")
588
+
589
+ args = parser.parse_args()
590
+
591
+ report = run_doctor(
592
+ config_path=args.config,
593
+ log_path=args.log,
594
+ hooks_path=args.hooks,
595
+ fix=args.fix,
596
+ verbose=args.verbose,
597
+ )
598
+
599
+ if args.json:
600
+ print(json.dumps(report, indent=2))
601
+ else:
602
+ print(format_doctor_report(report))
603
+
604
+ saved = save_doctor_report(report)
605
+ if not args.json:
606
+ print(f"\nSaved: {saved}")
607
+
608
+
609
+ if __name__ == "__main__":
610
+ main()