diff --git a/lib/clauck b/lib/clauck index d1f882d..8e4612e 100755 --- a/lib/clauck +++ b/lib/clauck @@ -122,6 +122,8 @@ KNOWN_COMMANDS = { "-h", "--help", "help", } +AUTO_REPORT_MODES = {"off", "draft", "auto"} + # ── helpers ────────────────────────────────────────────────────────────── @@ -158,7 +160,7 @@ def jit_update_check() -> None: def _notify_pending_auto_reports() -> None: """Print a one-line notice if background agents have queued auto-draft reports.""" - auto_report_mode = load_config().get("auto_report", {}).get("mode", "off") + auto_report_mode = _auto_report_mode() if auto_report_mode == "off" or not REPORTS_DIR.exists(): return auto_drafts = list(REPORTS_DIR.glob("*-auto.json")) @@ -188,6 +190,19 @@ def load_config() -> dict: return {} +def _auto_report_mode(config: dict | None = None) -> str: + """Return the normalized auto-report mode from current or provided config.""" + cfg = config if config is not None else load_config() + raw = cfg.get("auto_report", {}) + if isinstance(raw, dict): + mode = raw.get("mode", "off") + elif isinstance(raw, str): + mode = raw + else: + mode = "off" + return mode if isinstance(mode, str) and mode in AUTO_REPORT_MODES else "off" + + def _load_scheduler_module(): """Load scheduler.py from the dev tree or installed runtime, if present.""" candidates = ( @@ -499,7 +514,7 @@ def cmd_status() -> None: print(f" notifications: {'on' if notif else 'off'} (toggle: clauck config set notifications true/false)") # Check for pending auto-report drafts - auto_report_mode = config.get("auto_report", {}).get("mode", "off") + auto_report_mode = _auto_report_mode(config) print(f" auto-report: {auto_report_mode}" f" (change: clauck config set auto_report.mode draft|auto|off)") if auto_report_mode != "off" and REPORTS_DIR.exists(): @@ -3430,6 +3445,18 @@ def cmd_doctor( print(str(err_text).strip(), file=sys.stderr) sys.exit(1) + should_auto_draft = False + auto_draft_title = "" + auto_draft_dedupe = "" + if dry and claude_result: + combined = (claude_result + " " + fix_instruction).lower() + if any(f in combined for f in _CLAUCK_SYSTEM_FILES): + import hashlib as _hl + dedup_src = (fix_instruction or claude_result[:200]).encode() + auto_draft_dedupe = f"doctor-{_hl.md5(dedup_src).hexdigest()[:12]}" + auto_draft_title = (fix_instruction or "clauck system issue detected by doctor")[:120] + should_auto_draft = True + # Auto-prompt: if the diagnostic model queued a fix instruction and we're # running attached to a tty, offer to run it without the user retyping. The # instruction is passed as a single argv element to a re-invocation of this @@ -3444,6 +3471,14 @@ def cmd_doctor( print() return if resp in ("", "y", "yes"): + if should_auto_draft: + _write_auto_draft( + source="clauck-doctor", + title=auto_draft_title, + body=f"## Doctor finding\n\n{claude_result[:1500]}", + labels=["bug"], + dedupe_key=auto_draft_dedupe, + ) fix_argv = [sys.argv[0], "doctor", "--fix"] if not safe: # Preserve --unsafe only if the original invocation was --unsafe. @@ -3457,20 +3492,14 @@ def cmd_doctor( # REPORTS_DIR so the user can review and submit it later. The heuristic # is conservative: we only fire when the doctor report or fix_instruction # text references a known clauck internal file name. - if dry and claude_result and not is_error: - combined = (claude_result + " " + fix_instruction).lower() - if any(f in combined for f in _CLAUCK_SYSTEM_FILES): - import hashlib as _hl - dedup_src = (fix_instruction or claude_result[:200]).encode() - dedup = _hl.md5(dedup_src).hexdigest()[:12] - draft_title = (fix_instruction or "clauck system issue detected by doctor")[:120] - _write_auto_draft( - source="clauck-doctor", - title=draft_title, - body=f"## Doctor finding\n\n{claude_result[:1500]}", - labels=["bug"], - dedupe_key=f"doctor-{dedup}", - ) + if should_auto_draft: + _write_auto_draft( + source="clauck-doctor", + title=auto_draft_title, + body=f"## Doctor finding\n\n{claude_result[:1500]}", + labels=["bug"], + dedupe_key=auto_draft_dedupe, + ) # Optional interactive follow-up on a resolvable session. if interactive and session_id: @@ -3813,7 +3842,7 @@ def _write_auto_draft( """ import time as _t - auto_report_mode = load_config().get("auto_report", {}).get("mode", "off") + auto_report_mode = _auto_report_mode() if auto_report_mode == "off": return None diff --git a/tests/test_clauck_report.py b/tests/test_clauck_report.py index c7bebc5..eb01088 100644 --- a/tests/test_clauck_report.py +++ b/tests/test_clauck_report.py @@ -8,6 +8,7 @@ import importlib.machinery import io import json +import subprocess import sys import tempfile import time @@ -317,6 +318,12 @@ def test_auto_mode_creates_file(self): self.assertIsNotNone(path) self.assertTrue(path.exists()) + def test_legacy_scalar_mode_string_still_writes(self): + self.config_file.write_text(json.dumps({"auto_report": "draft"})) + path = _write_auto_draft("doctor", "A bug", "body") + self.assertIsNotNone(path) + self.assertTrue(path.exists()) + def test_custom_labels_stored(self): self._set_mode("draft") path = _write_auto_draft("agent", "t", "b", labels=["enhancement"]) @@ -427,3 +434,171 @@ def test_notice_includes_count(self): (self.reports / f"20260418T12000{i}Z-doctor-auto.json").write_text("{}") out = self._capture_notify() self.assertIn("3", out) + + def test_legacy_scalar_mode_string_still_prints_notice(self): + self.config_file.write_text(json.dumps({"auto_report": "draft"})) + self.reports.mkdir() + (self.reports / "20260418T120000Z-doctor-auto.json").write_text("{}") + out = self._capture_notify() + self.assertIn("auto-report", out) + + +class TestLegacyAutoReportConfigCompatibility(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.jobs_dir = root / "jobs" + self.jobs_dir.mkdir() + self.state_dir = self.jobs_dir / ".state" + self.state_dir.mkdir() + self.reports_dir = root / "reports" + self.reports_dir.mkdir() + self.config_file = root / "config.json" + self.version_file = root / ".version" + self.version_file.write_text("v1.5.7") + self.manifest_file = root / ".manifest.json" + self.manifest_file.write_text(json.dumps({"jobs": []})) + self._orig_jobs = _mod.JOBS_DIR + self._orig_state = _mod.STATE_DIR + self._orig_reports = _mod.REPORTS_DIR + self._orig_config = _mod.CONFIG_FILE + self._orig_version = _mod.VERSION_FILE + self._orig_manifest = _mod.MANIFEST + _mod.JOBS_DIR = self.jobs_dir + _mod.STATE_DIR = self.state_dir + _mod.REPORTS_DIR = self.reports_dir + _mod.CONFIG_FILE = self.config_file + _mod.VERSION_FILE = self.version_file + _mod.MANIFEST = self.manifest_file + + def tearDown(self): + _mod.JOBS_DIR = self._orig_jobs + _mod.STATE_DIR = self._orig_state + _mod.REPORTS_DIR = self._orig_reports + _mod.CONFIG_FILE = self._orig_config + _mod.VERSION_FILE = self._orig_version + _mod.MANIFEST = self._orig_manifest + self.tmp.cleanup() + + def test_main_list_does_not_brick_on_legacy_scalar_mode(self): + self.config_file.write_text(json.dumps({"auto_report": "draft"})) + with patch.object(_mod, "jit_update_check"), \ + patch.object(_mod, "cmd_list") as mock_list, \ + patch.object(sys, "argv", ["clauck", "list"]): + _mod.main() + mock_list.assert_called_once_with(tag_filter="") + + def test_status_handles_legacy_scalar_mode_and_pending_drafts(self): + self.config_file.write_text(json.dumps({"auto_report": "draft"})) + (self.reports_dir / "20260418T120000Z-doctor-auto.json").write_text("{}") + buf = io.StringIO() + with patch("builtins.print", side_effect=lambda *a, **k: buf.write(" ".join(str(x) for x in a) + "\n")), \ + patch.object(_mod.subprocess, "run", return_value=subprocess.CompletedProcess(["launchctl", "list"], 0, "", "")): + _mod.cmd_status() + out = buf.getvalue() + self.assertIn("auto-report: draft", out) + self.assertIn("auto-report drafts: 1 pending", out) + + +class TestDoctorAutoReportHandoff(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.jobs_dir = root / "jobs" + self.jobs_dir.mkdir() + self.state_dir = self.jobs_dir / ".state" + self.state_dir.mkdir() + self.reports_dir = root / "reports" + self.config_file = root / "config.json" + self.config_file.write_text(json.dumps({"auto_report": {"mode": "draft"}})) + self._orig_jobs = _mod.JOBS_DIR + self._orig_state = _mod.STATE_DIR + self._orig_reports = _mod.REPORTS_DIR + self._orig_config = _mod.CONFIG_FILE + _mod.JOBS_DIR = self.jobs_dir + _mod.STATE_DIR = self.state_dir + _mod.REPORTS_DIR = self.reports_dir + _mod.CONFIG_FILE = self.config_file + + def tearDown(self): + _mod.JOBS_DIR = self._orig_jobs + _mod.STATE_DIR = self._orig_state + _mod.REPORTS_DIR = self._orig_reports + _mod.CONFIG_FILE = self._orig_config + self.tmp.cleanup() + + def test_dry_fix_prompt_writes_auto_draft_before_exec_handoff(self): + class ExecHandoff(Exception): + pass + + class DummyThread: + def __init__(self, target=None, daemon=None): + self.target = target + + def start(self): + return None + + def join(self, timeout=None): + return None + + stage1 = subprocess.CompletedProcess( + ["claude", "-p"], + 0, + json.dumps({ + "result": json.dumps({ + "interpretation": "Inspect install.sh drift", + "task_complexity_scale": 0.2, + }), + "is_error": False, + }), + "", + ) + stage2 = subprocess.CompletedProcess( + ["claude", "-p"], + 0, + json.dumps({ + "result": json.dumps({ + "report": "install.sh needs a clauck internal fix", + "fix_instruction": "repair install.sh handling", + }), + "session_id": "session-123", + "is_error": False, + }), + "", + ) + + events = [] + + def fake_write_auto_draft(*args, **kwargs): + events.append("draft") + return self.reports_dir / "draft.json" + + def fake_execvp(path, argv): + events.append("execvp") + raise ExecHandoff() + + with patch.object(_mod, "_find_claude", return_value="/usr/bin/claude"), \ + patch.object(_mod.sizing, "load_doctor_config", return_value={"scale_skew": 0.0, "max_budget_usd": 10.0}), \ + patch.object(_mod.sizing, "estimate_tokens", return_value=0), \ + patch.object(_mod.sizing, "compute_sizing", return_value={ + "model": "haiku", + "effort": "low", + "max_turns": 2, + "max_budget_usd": 1.0, + "explanation": "scaled", + "base_cost": 0.0, + "context_cost": 0.0, + "headroom": 1.3, + }), \ + patch.object(_mod.subprocess, "run", side_effect=[stage1, stage2]), \ + patch.object(_mod, "_write_auto_draft", side_effect=fake_write_auto_draft), \ + patch("threading.Thread", DummyThread), \ + patch.object(sys.stdin, "isatty", return_value=True), \ + patch.object(sys.stdout, "isatty", return_value=True), \ + patch("builtins.input", return_value=""), \ + patch.object(_mod.os, "execvp", side_effect=fake_execvp), \ + patch.object(sys, "argv", ["clauck", "doctor", "--dry", "check install.sh"]): + with self.assertRaises(ExecHandoff): + _mod.cmd_doctor(dry=True, fix=False, safe=True, unsafe=False, interactive=False, context="check install.sh") + + self.assertEqual(events, ["draft", "execvp"])