diff --git a/docs/scanner-registry.md b/docs/scanner-registry.md index 5fc90c69..e4a96324 100644 --- a/docs/scanner-registry.md +++ b/docs/scanner-registry.md @@ -109,7 +109,7 @@ Fields: - `id`: stable scanner id. - `command`: command the operator or wrapper may run explicitly. - `source`: expected work import source. -- `cadence`: `daily@HH:MM` or `hourly@MM`. +- `cadence`: `daily@HH:MM`, `weekly@HH:MM`, or `hourly@MM`. - `enabled`: true or false. - `timeout`: expected max runtime in seconds. - `output_path`: local output or state file used for freshness checks. @@ -118,6 +118,6 @@ Fields: - `conflict_window`: `HH:MM-HH:MM` window that should not overlap related jobs. - `cwd` or `target`: optional repo-relative working directory for execution. -Default local producers cover chat sweep imports, memory refresh imports, handoff ingest sync, security findings, and optional disabled memory-care, backup-health, and tool-catalog entries. Product-specific chat adapters and tool projection writers remain outside this registry phase. +Default local producers cover chat sweep imports, memory refresh imports, handoff ingest sync, security findings, a weekly workspace friction scan, and optional disabled memory-care, backup-health, and tool-catalog entries. Product-specific chat adapters and tool projection writers remain outside this registry phase. Project consolidation, learning-loop, context-pack, and operator-center commands are not scanner executions by themselves. They can still route reviewable work into the same inbox through `brigade projects import-issues`, `brigade learn import-issues`, and subsystem-specific import commands. The operator-center commands are read-only summaries and never ingest, promote, or execute scanner output. diff --git a/src/brigade/work_cmd/config.py b/src/brigade/work_cmd/config.py index c2d6128a..73cca6fd 100644 --- a/src/brigade/work_cmd/config.py +++ b/src/brigade/work_cmd/config.py @@ -648,7 +648,7 @@ def _load_scanner_config(target: Path) -> tuple[list[dict[str, Any]], list[str]] errors.append(f"{label}: duplicate id {scanner_id}") seen_ids.add(scanner_id) if "cadence" in scanner and _scanner_start_minute(scanner["cadence"]) is None: - errors.append(f"{label}: cadence must be daily@HH:MM or hourly@MM") + errors.append(f"{label}: cadence must be daily@HH:MM, weekly@HH:MM, or hourly@MM") if "conflict_window" in scanner and _scanner_window_minutes(scanner["conflict_window"]) is None: errors.append(f"{label}: conflict_window must be HH:MM-HH:MM") if scanner: @@ -778,6 +778,9 @@ def _scanner_start_minute(cadence: str) -> int | None: daily = re.fullmatch(r"daily@(.+)", cadence.strip()) if daily: return _parse_clock_minutes(daily.group(1)) + weekly = re.fullmatch(r"weekly@(.+)", cadence.strip()) + if weekly: + return _parse_clock_minutes(weekly.group(1)) hourly = re.fullmatch(r"hourly@([0-5]?\d)", cadence.strip()) if hourly: return int(hourly.group(1)) diff --git a/src/brigade/work_cmd/constants.py b/src/brigade/work_cmd/constants.py index 3d07762d..886cec9c 100644 --- a/src/brigade/work_cmd/constants.py +++ b/src/brigade/work_cmd/constants.py @@ -189,6 +189,11 @@ SCANNER_RUN_STALE_HOURS = 48 +# Weekly scanners are due every 7 days; keep the stale threshold a day past due +# so a successful weekly run does not WARN for most of the cycle. +SCANNER_WEEKLY_STALE_HOURS = 192 + + SCANNER_SWEEP_STALE_HOURS = 36 @@ -288,6 +293,16 @@ "output_path": ".brigade/tools.toml", "conflict_window": "04:20-04:40", }, + { + "id": "friction-scan", + "source": "friction-scan", + "command": "brigade friction scan --json", + "cadence": "weekly@05:00", + "enabled": True, + "timeout": 300, + "output_path": ".brigade/friction/latest.json", + "conflict_window": "04:50-05:15", + }, ) diff --git a/src/brigade/work_cmd/scanners.py b/src/brigade/work_cmd/scanners.py index 0c68e5fe..9f812fe4 100644 --- a/src/brigade/work_cmd/scanners.py +++ b/src/brigade/work_cmd/scanners.py @@ -80,6 +80,14 @@ def _scanner_latest_success(target: Path, scanner_id: str) -> dict[str, Any] | N return None +def _scanner_stale_hours(cadence: str) -> float: + """Return the freshness WARN threshold for a scanner cadence.""" + value = (cadence or "").strip() + if value.startswith("weekly@"): + return float(constants.SCANNER_WEEKLY_STALE_HOURS) + return float(constants.SCANNER_OUTPUT_STALE_HOURS) + + def _scanner_is_due(target: Path, scanner: dict[str, Any], *, now: datetime | None = None) -> bool: now = now or helpers._now() scanner_id = str(scanner.get("id") or "") @@ -94,6 +102,8 @@ def _scanner_is_due(target: Path, scanner: dict[str, Any], *, now: datetime | No return (now - started).total_seconds() >= 3600 if cadence.startswith("daily@"): return now.date() > started.date() + if cadence.startswith("weekly@"): + return (now - started).total_seconds() >= 7 * 24 * 3600 return False @@ -416,14 +426,19 @@ def _scanner_plan_payload(target: Path) -> dict[str, Any]: for item in planned: current = int(item["start_minute"]) suggested = current if next_start is None else max(current, next_start) + cadence_value = str(item.get("cadence", "")) + if cadence_value.startswith("daily@"): + suggested_cadence = f"daily@{config_mod._format_clock_minutes(suggested)}" + elif cadence_value.startswith("weekly@"): + suggested_cadence = f"weekly@{config_mod._format_clock_minutes(suggested)}" + else: + suggested_cadence = f"hourly@{suggested % 60:02d}" suggestions.append( { "id": item["id"], "current": item["cadence"], "suggested_start": config_mod._format_clock_minutes(suggested), - "suggested_cadence": f"daily@{config_mod._format_clock_minutes(suggested)}" - if str(item.get("cadence", "")).startswith("daily@") - else f"hourly@{suggested % 60:02d}", + "suggested_cadence": suggested_cadence, } ) next_start = suggested + 15 @@ -526,7 +541,7 @@ def _scanner_health(target: Path) -> dict[str, Any]: if now is None: continue age_hours = (now.timestamp() - path.stat().st_mtime) / 3600 - if age_hours > constants.SCANNER_OUTPUT_STALE_HOURS: + if age_hours > _scanner_stale_hours(str(scanner.get("cadence") or "")): stale_outputs.append(f"{scanner.get('id')}={age_hours:.1f}h") if missing_outputs or stale_outputs: parts = [] @@ -607,7 +622,7 @@ def _scanner_health(target: Path) -> dict[str, Any]: stale_successes.append(str(scanner.get("id"))) continue age_hours = (now - completed).total_seconds() / 3600 - if age_hours > constants.SCANNER_RUN_STALE_HOURS: + if age_hours > _scanner_stale_hours(str(scanner.get("cadence") or "")): stale_successes.append(f"{scanner.get('id')}={age_hours:.1f}h") if stale_successes: checks.append( diff --git a/tests/test_work_cmd_scanners.py b/tests/test_work_cmd_scanners.py index 672c1091..e62116ca 100644 --- a/tests/test_work_cmd_scanners.py +++ b/tests/test_work_cmd_scanners.py @@ -65,7 +65,7 @@ def test_work_scanners_init_list_show_plan_and_json(tmp_path, monkeypatch, capsy out = capsys.readouterr().out config = tmp_path / ".brigade" / "scanners.toml" assert f"scanner_config: {config}" in out - assert "scanners: 8" in out + assert "scanners: 9" in out assert ".brigade/scanners.toml" in (tmp_path / ".gitignore").read_text() assert work_cmd.scanners_list(target=tmp_path) == 0 @@ -73,6 +73,7 @@ def test_work_scanners_init_list_show_plan_and_json(tmp_path, monkeypatch, capsy assert "work scanners:" in out assert "- chat-memory-sweep [enabled] daily@02:15 source=chat-memory-sweep" in out assert "brigade work import chat-sweep --json" in out + assert "- friction-scan [enabled] weekly@05:00 source=friction-scan" in out assert work_cmd.scanners_list(target=tmp_path, json_output=True) == 0 payload = json.loads(capsys.readouterr().out) @@ -838,4 +839,76 @@ def test_scanners_run_ingest_skips_self_importing_scanner(tmp_path, capsys): payload = json.loads(capsys.readouterr().out) assert payload["completed"] == 1 assert payload["failed"] == 0 - assert payload["ingest_errors"] == [] + + +def test_scanner_weekly_cadence_parses_and_due_after_seven_days(monkeypatch): + from datetime import datetime, timedelta, timezone + from pathlib import Path + + from brigade.work_cmd import config as config_mod + from brigade.work_cmd import scanners as scanners_mod + + assert config_mod._scanner_start_minute("weekly@03:45") == 3 * 60 + 45 + assert config_mod._scanner_start_minute("weekly@99:99") is None + + now = datetime(2026, 7, 25, 12, 0, 0, tzinfo=timezone.utc) + monkeypatch.setattr(scanners_mod.helpers, "_now", lambda: now) + + scanner = {"id": "friction-scan", "cadence": "weekly@03:45", "enabled": True} + + monkeypatch.setattr(scanners_mod, "_scanner_latest_success", lambda target, scanner_id: None) + assert scanners_mod._scanner_is_due(Path("."), scanner, now=now) is True + + recent = {"completed_at": (now - timedelta(days=6)).isoformat()} + monkeypatch.setattr(scanners_mod, "_scanner_latest_success", lambda target, scanner_id: recent) + assert scanners_mod._scanner_is_due(Path("."), scanner, now=now) is False + + stale = {"completed_at": (now - timedelta(days=7)).isoformat()} + monkeypatch.setattr(scanners_mod, "_scanner_latest_success", lambda target, scanner_id: stale) + assert scanners_mod._scanner_is_due(Path("."), scanner, now=now) is True + + +def test_scanner_config_accepts_weekly_cadence(tmp_path): + from brigade.work_cmd import config as config_mod + + brigade = tmp_path / ".brigade" + brigade.mkdir() + (brigade / "scanners.toml").write_text( + "\n".join( + [ + "[[scanner]]", + 'id = "friction-scan"', + 'source = "friction-scan"', + 'command = "brigade friction scan --json"', + 'cadence = "weekly@05:00"', + "enabled = true", + "timeout = 300", + 'output_path = ".brigade/friction/latest.json"', + 'conflict_window = "04:50-05:15"', + "", + ] + ) + ) + scanners, errors = config_mod._load_scanner_config(tmp_path) + assert errors == [] + assert scanners[0]["cadence"] == "weekly@05:00" + + +def test_scanner_defaults_include_weekly_friction_scan(): + from brigade.work_cmd import constants + + entry = next(item for item in constants.SCANNER_DEFAULTS if item["id"] == "friction-scan") + assert entry["command"] == "brigade friction scan --json" + assert entry["cadence"] == "weekly@05:00" + assert entry["enabled"] is True + assert entry["output_path"] == ".brigade/friction/latest.json" + assert entry["conflict_window"] == "04:50-05:15" + + +def test_scanner_weekly_stale_threshold_is_longer_than_daily(): + from brigade.work_cmd import constants + from brigade.work_cmd import scanners as scanners_mod + + assert scanners_mod._scanner_stale_hours("daily@02:15") == constants.SCANNER_OUTPUT_STALE_HOURS + assert scanners_mod._scanner_stale_hours("weekly@05:00") == constants.SCANNER_WEEKLY_STALE_HOURS + assert constants.SCANNER_WEEKLY_STALE_HOURS > 7 * 24