Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 46 additions & 17 deletions lib/clauck
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ KNOWN_COMMANDS = {
"-h", "--help", "help",
}

AUTO_REPORT_MODES = {"off", "draft", "auto"}


# ── helpers ──────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down
175 changes: 175 additions & 0 deletions tests/test_clauck_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import importlib.machinery
import io
import json
import subprocess
import sys
import tempfile
import time
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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"])
Loading