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
4 changes: 2 additions & 2 deletions docs/scanner-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
5 changes: 4 additions & 1 deletion src/brigade/work_cmd/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down
15 changes: 15 additions & 0 deletions src/brigade/work_cmd/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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",
},
)


Expand Down
25 changes: 20 additions & 5 deletions src/brigade/work_cmd/scanners.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand All @@ -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


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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(
Expand Down
77 changes: 75 additions & 2 deletions tests/test_work_cmd_scanners.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ 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
out = capsys.readouterr().out
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)
Expand Down Expand Up @@ -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
Loading