Skip to content

Commit 3b72707

Browse files
committed
feat: add handoff source doctor
1 parent 7c6eb61 commit 3b72707

14 files changed

Lines changed: 410 additions & 2 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252
- `brigade work import triage` to group pending imports by source and kind.
5353
- `brigade work import dismiss` to close noisy imports without promoting them.
5454
- `brigade work import promote --all` with optional `--source` and `--kind` filters for batch promotion.
55+
- `brigade handoff doctor` to compare pending `.claude` and `.codex` memory handoffs against gitignored local source config.
56+
- Repo installs now include `.brigade/handoff-sources.example.json` as the local handoff ingestor source-list contract.
5557
- `docs/import-schema.md` documenting the local import JSONL contract for scanners and wrappers.
5658
- Cybersecurity plugin roadmap covering broad agent-workspace security checks plus Brigade-specific scanner, doctor, import, and multi-harness security checks.
5759
- Built-in `security` station and `brigade security scan` for read-only agent workspace security checks.
@@ -85,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8587
- `brigade work task add --from-next` now reuses an equivalent pending task instead of adding duplicates.
8688
- `brigade work brief` now includes pending local work imports and import counts in both text and JSON output.
8789
- The managed gitignore block now treats `.brigade/dogfood.toml`, `.brigade/security.toml`, `.brigade/runs/`, and `.brigade/security/` as local state.
90+
- The managed gitignore block now treats `.brigade/handoff-sources.json` as host-local state.
8891
- Live smoke docs now keep Codex agent execution in a trusted repo cwd while writing temporary roster, artifacts, and handoff output under `/tmp`.
8992
- Handoff write failures now preserve final run artifacts, print the final answer, return nonzero, and mark `run.json` as `handoff-failed`.
9093
- Dogfood runs default to prompt-level read-only plus Codex's `danger-full-access` sandbox setting for trusted-workspace use so repo inspection works on hosts where native read-only sandboxing blocks shell inspection; `--native-read-only-sandbox` opts into stricter native enforcement.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ brigade init --target ./repo --harnesses none # generic install
9393
```
9494

9595
Once installed, `brigade doctor` verifies the wiring and `brigade status` reports over the station registry.
96+
For machines that ingest handoffs from multiple repos, copy `.brigade/handoff-sources.example.json` to `.brigade/handoff-sources.json` and list the repo roots and writer inboxes the canonical ingestor scans.
97+
`brigade handoff doctor` reports pending `.claude/memory-handoffs/` and `.codex/memory-handoffs/` files that are not covered by that local source list.
9698

9799
## Run a brigade
98100

@@ -150,6 +152,7 @@ brigade dogfood status
150152
brigade dogfood
151153
brigade dogfood next
152154
brigade dogfood --target /path/to/repo
155+
brigade handoff doctor
153156
brigade work bootstrap
154157
brigade work status
155158
brigade work doctor

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ Goal: prevent durable memory from silently rotting.
110110
- Promote refresh candidates into tasks or memory handoffs after review.
111111
- Auto-fix only within safe gates where source evidence is current, low-risk, and locally reviewable.
112112
- Treat bootstrap truncation as a hard failure. Bootstrap files stay slim, cards hold durable detail, and doctor checks enforce the boundary.
113-
- Add a handoff doctor that compares repo-local writer inboxes such as `.claude/memory-handoffs/` and `.codex/memory-handoffs/` against the canonical ingestor source list, warning when handoffs exist in directories the owner is not scanning.
113+
- Add a handoff doctor that compares repo-local writer inboxes such as `.claude/memory-handoffs/` and `.codex/memory-handoffs/` against the canonical ingestor source list, warning when handoffs exist in directories the owner is not scanning. Status: started with `brigade handoff doctor`, `.brigade/handoff-sources.example.json`, and `brigade doctor` / `brigade work doctor` integration.
114114
- Add handoff-ingest observability checks for hidden warning states, including unreachable remote sources, malformed handoffs that are skipped, and runs that emit `NO_REPLY` despite warnings.
115115

116116
## Later Phase: Portable Operator Setup

src/brigade/cli.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ def _build_parser() -> argparse.ArgumentParser:
107107
)
108108
p_dogfood.add_argument("--timeout-seconds", type=float, default=DEFAULT_TIMEOUT_SECONDS, help="Per-agent timeout.")
109109

110+
# handoff
111+
p_handoff = sub.add_parser("handoff", help="Inspect memory handoff inbox health.")
112+
handoff_sub = p_handoff.add_subparsers(dest="handoff_command", metavar="<handoff-command>")
113+
handoff_sub.required = True
114+
p_handoff_doctor = handoff_sub.add_parser("doctor", help="Check handoff inboxes against local source config.")
115+
p_handoff_doctor.add_argument("--target", "-t", type=Path, default=Path("."), help="Repo or workspace to inspect.")
116+
p_handoff_doctor.add_argument("--sources", type=Path, default=None, help="Override .brigade/handoff-sources.json.")
117+
p_handoff_doctor.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
118+
110119
# work
111120
p_work = sub.add_parser("work", help="Inspect and manage a daily Brigade work session.")
112121
work_sub = p_work.add_subparsers(dest="work_command", metavar="<work-command>")
@@ -626,6 +635,13 @@ def main(argv=None) -> int:
626635
native_read_only_sandbox=args.native_read_only_sandbox,
627636
timeout_seconds=args.timeout_seconds,
628637
)
638+
if cmd == "handoff":
639+
from . import handoff_cmd
640+
641+
if args.handoff_command == "doctor":
642+
return handoff_cmd.doctor(target=args.target, sources=args.sources, json_output=args.json)
643+
parser.error(f"unknown handoff command: {args.handoff_command}")
644+
return 2
629645
if cmd == "work":
630646
from . import work_cmd
631647

src/brigade/doctor.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def core_station_checks(ctx: DoctorContext) -> List[CheckResult]:
6868
def memory_station_checks(ctx: DoctorContext) -> List[CheckResult]:
6969
checks: List[CheckResult] = []
7070
checks.extend(_check_handoff_inboxes(ctx.target, ctx.selection, ctx.harnesses))
71+
checks.extend(_check_handoff_sources(ctx.target))
7172
checks.extend(_check_memory_cards(ctx.target))
7273
checks.extend(_check_memory_index(ctx.target))
7374
checks.extend(_check_memory_care(ctx.target))
@@ -279,6 +280,16 @@ def _check_handoff_inboxes(
279280
return results
280281

281282

283+
def _check_handoff_sources(target: Path) -> List[CheckResult]:
284+
from . import handoff_cmd
285+
286+
mapping = {handoff_cmd.OK: OK, handoff_cmd.WARN: WARN, handoff_cmd.FAIL: FAIL}
287+
return [
288+
(mapping.get(status, WARN), f"handoff-source: {name}", detail)
289+
for status, name, detail in handoff_cmd.doctor_checks(target)
290+
]
291+
292+
282293
def _check_memory_index(target: Path) -> List[CheckResult]:
283294
index = target / "MEMORY.md"
284295
if not index.is_file():

src/brigade/handoff_cmd.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Handoff health checks shared by CLI doctors."""
2+
from __future__ import annotations
3+
4+
import json
5+
import sys
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
from typing import Any
9+
10+
OK = "ok"
11+
WARN = "warn"
12+
FAIL = "fail"
13+
14+
WRITER_INBOXES = (".claude/memory-handoffs", ".codex/memory-handoffs")
15+
IGNORED_HANDOFF_NAMES = {"TEMPLATE.md"}
16+
17+
18+
@dataclass(frozen=True)
19+
class WatchedInbox:
20+
root: Path
21+
inbox: str
22+
23+
24+
@dataclass(frozen=True)
25+
class InboxHealth:
26+
inbox: str
27+
path: Path
28+
exists: bool
29+
pending: int
30+
processed: int
31+
watched: bool
32+
33+
def as_dict(self) -> dict[str, Any]:
34+
return {
35+
"inbox": self.inbox,
36+
"path": str(self.path),
37+
"exists": self.exists,
38+
"pending": self.pending,
39+
"processed": self.processed,
40+
"watched": self.watched,
41+
}
42+
43+
44+
@dataclass(frozen=True)
45+
class HandoffHealth:
46+
target: Path
47+
sources_path: Path | None
48+
sources_loaded: bool
49+
inboxes: tuple[InboxHealth, ...]
50+
warnings: tuple[str, ...]
51+
failures: tuple[str, ...]
52+
53+
def as_dict(self) -> dict[str, Any]:
54+
return {
55+
"target": str(self.target),
56+
"sources_path": str(self.sources_path) if self.sources_path else None,
57+
"sources_loaded": self.sources_loaded,
58+
"inboxes": [inbox.as_dict() for inbox in self.inboxes],
59+
"warnings": list(self.warnings),
60+
"failures": list(self.failures),
61+
}
62+
63+
64+
def default_sources_path(target: Path) -> Path:
65+
return target / ".brigade" / "handoff-sources.json"
66+
67+
68+
def inspect(target: Path, sources: Path | None = None) -> HandoffHealth:
69+
target = target.expanduser().resolve()
70+
sources_path = sources.expanduser().resolve() if sources is not None else default_sources_path(target)
71+
watched: tuple[WatchedInbox, ...] = ()
72+
failures: list[str] = []
73+
sources_loaded = False
74+
75+
if sources_path.is_file():
76+
try:
77+
watched = _load_sources(target, sources_path)
78+
except ValueError as exc:
79+
failures.append(f"invalid handoff source config {sources_path}: {exc}")
80+
else:
81+
sources_loaded = True
82+
elif sources is not None:
83+
failures.append(f"handoff source config not found: {sources_path}")
84+
sources_path = sources_path
85+
else:
86+
sources_path = None
87+
88+
inboxes = tuple(_inspect_inbox(target, rel, watched) for rel in WRITER_INBOXES)
89+
warnings: list[str] = []
90+
pending_total = sum(inbox.pending for inbox in inboxes)
91+
if pending_total and not sources_loaded and not failures:
92+
warnings.append(
93+
"pending handoffs exist but no .brigade/handoff-sources.json is configured"
94+
)
95+
for inbox in inboxes:
96+
if inbox.pending and not inbox.watched:
97+
warnings.append(
98+
f"{inbox.inbox} has {inbox.pending} pending handoff"
99+
f"{'s' if inbox.pending != 1 else ''} but is not watched by the source config"
100+
)
101+
102+
return HandoffHealth(
103+
target=target,
104+
sources_path=sources_path,
105+
sources_loaded=sources_loaded,
106+
inboxes=inboxes,
107+
warnings=tuple(warnings),
108+
failures=tuple(failures),
109+
)
110+
111+
112+
def doctor_checks(target: Path, sources: Path | None = None) -> list[tuple[str, str, str]]:
113+
health = inspect(target, sources=sources)
114+
checks: list[tuple[str, str, str]] = []
115+
if health.failures:
116+
for failure in health.failures:
117+
checks.append((FAIL, "handoff_sources", failure))
118+
elif health.sources_loaded:
119+
checks.append((OK, "handoff_sources", str(health.sources_path)))
120+
else:
121+
pending_total = sum(inbox.pending for inbox in health.inboxes)
122+
level = WARN if pending_total else OK
123+
checks.append((level, "handoff_sources", "not configured; no pending handoffs" if not pending_total else "not configured"))
124+
125+
for inbox in health.inboxes:
126+
if inbox.pending and not inbox.watched:
127+
level = WARN
128+
elif not inbox.exists:
129+
level = OK
130+
else:
131+
level = OK
132+
watched = "yes" if inbox.watched else "no"
133+
exists = "yes" if inbox.exists else "no"
134+
detail = (
135+
f"{inbox.path} "
136+
f"(exists={exists}, pending={inbox.pending}, processed={inbox.processed}, watched={watched})"
137+
)
138+
checks.append((level, f"handoff_watch: {inbox.inbox}", detail))
139+
140+
for warning in health.warnings:
141+
checks.append((WARN, "handoff_warning", warning))
142+
return checks
143+
144+
145+
def doctor(*, target: Path, sources: Path | None = None, json_output: bool = False) -> int:
146+
if not target.expanduser().exists():
147+
print(f"error: target does not exist: {target}", file=sys.stderr)
148+
return 2
149+
health = inspect(target, sources=sources)
150+
if json_output:
151+
print(json.dumps(health.as_dict(), indent=2, sort_keys=True))
152+
else:
153+
print(f"handoff doctor: {health.target}")
154+
print(f"sources: {health.sources_path if health.sources_path else '(not configured)'}")
155+
for status, name, detail in doctor_checks(health.target, sources=health.sources_path):
156+
print(f"[{status}] {name}: {detail}")
157+
return 1 if health.failures else 0
158+
159+
160+
def _load_sources(target: Path, sources_path: Path) -> tuple[WatchedInbox, ...]:
161+
try:
162+
payload = json.loads(sources_path.read_text())
163+
except json.JSONDecodeError as exc:
164+
raise ValueError(f"invalid JSON: {exc}") from exc
165+
if not isinstance(payload, dict):
166+
raise ValueError("root must be a JSON object")
167+
sources = payload.get("sources")
168+
if not isinstance(sources, list):
169+
raise ValueError("sources must be a list")
170+
171+
watched: list[WatchedInbox] = []
172+
for index, entry in enumerate(sources):
173+
if isinstance(entry, str):
174+
root_value = entry
175+
inbox_values = list(WRITER_INBOXES)
176+
elif isinstance(entry, dict):
177+
root_value = entry.get("root", ".")
178+
inbox_values = entry.get("inboxes", list(WRITER_INBOXES))
179+
else:
180+
raise ValueError(f"sources[{index}] must be an object or string")
181+
if not isinstance(root_value, str) or not root_value.strip():
182+
raise ValueError(f"sources[{index}].root must be a non-empty string")
183+
if not isinstance(inbox_values, list) or not all(isinstance(item, str) for item in inbox_values):
184+
raise ValueError(f"sources[{index}].inboxes must be a list of strings")
185+
root = _resolve_source_root(target, root_value)
186+
for inbox in inbox_values:
187+
normalized = _normalize_inbox(inbox)
188+
if normalized:
189+
watched.append(WatchedInbox(root=root, inbox=normalized))
190+
return tuple(watched)
191+
192+
193+
def _resolve_source_root(target: Path, value: str) -> Path:
194+
path = Path(value).expanduser()
195+
if not path.is_absolute():
196+
path = target / path
197+
return path.resolve()
198+
199+
200+
def _normalize_inbox(value: str) -> str:
201+
normalized = value.strip().replace("\\", "/").strip("/")
202+
if normalized.startswith("./"):
203+
normalized = normalized[2:]
204+
return normalized
205+
206+
207+
def _inspect_inbox(target: Path, rel: str, watched: tuple[WatchedInbox, ...]) -> InboxHealth:
208+
path = target / rel
209+
return InboxHealth(
210+
inbox=rel,
211+
path=path,
212+
exists=path.is_dir(),
213+
pending=_count_pending(path),
214+
processed=_count_processed(path),
215+
watched=_is_watched(target, rel, watched),
216+
)
217+
218+
219+
def _count_pending(path: Path) -> int:
220+
if not path.is_dir():
221+
return 0
222+
count = 0
223+
for candidate in path.glob("*.md"):
224+
if not candidate.is_file():
225+
continue
226+
if candidate.name.startswith(".") or candidate.name in IGNORED_HANDOFF_NAMES:
227+
continue
228+
count += 1
229+
return count
230+
231+
232+
def _count_processed(path: Path) -> int:
233+
processed = path / "processed"
234+
if not processed.is_dir():
235+
return 0
236+
return len([candidate for candidate in processed.glob("*.md") if candidate.is_file()])
237+
238+
239+
def _is_watched(target: Path, rel: str, watched: tuple[WatchedInbox, ...]) -> bool:
240+
resolved_target = target.resolve()
241+
normalized = _normalize_inbox(rel)
242+
return any(item.root == resolved_target and item.inbox == normalized for item in watched)

src/brigade/install.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def build_gitignore_block(selection: Selection) -> str:
6262
"",
6363
"# brigade local state (logs, scrub cache, dogfood runs, work sessions).",
6464
".brigade/dogfood.toml",
65+
".brigade/handoff-sources.json",
6566
".brigade/security.toml",
6667
".brigade/logs/",
6768
".brigade/runs/",

src/brigade/templates/depth/repo.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
{"src": "workspace/AGENTS.md", "dst": "AGENTS.md"},
66
{"src": "workspace/SAFETY_RULES.md", "dst": "SAFETY_RULES.md"},
77
{"src": "workspace/INSTALL_FOR_AGENTS.md", "dst": "INSTALL_FOR_AGENTS.md"},
8+
{"src": "handoff/handoff-sources.example.json", "dst": ".brigade/handoff-sources.example.json"},
89
{"src": "hooks/pre-push", "dst": "hooks/pre-push", "mode": "0755"},
910
{"src": "policies/public-repo.json", "dst": ".brigade/policies/public-repo.json"}
1011
],
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"_description": "Copy to .brigade/handoff-sources.json and edit for this machine. Relative roots resolve from the repo or workspace target.",
3+
"canonical_owner": "openclaw",
4+
"sources": [
5+
{
6+
"root": ".",
7+
"inboxes": [
8+
".claude/memory-handoffs",
9+
".codex/memory-handoffs"
10+
]
11+
}
12+
]
13+
}

src/brigade/work_cmd.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2344,7 +2344,7 @@ def status(*, target: Path, limit: int = 12) -> int:
23442344

23452345

23462346
def doctor(*, target: Path) -> int:
2347-
from . import security_cmd
2347+
from . import handoff_cmd, security_cmd
23482348

23492349
target = target.expanduser().resolve()
23502350
failures = 0
@@ -2471,6 +2471,11 @@ def doctor(*, target: Path) -> int:
24712471
handoff_ignored = dogfood_cmd._check_git_ignored(effective_target, handoff_inbox)
24722472
_doctor_line(_doctor_ignore_level(handoff_ignored), "handoff_ignored", handoff_ignored)
24732473

2474+
for status, name, detail in handoff_cmd.doctor_checks(effective_target):
2475+
if status == FAIL:
2476+
failures += 1
2477+
_doctor_line(status, name, detail)
2478+
24742479
latest = dogfood_cmd._latest_run(artifacts_dir)
24752480
if latest is None:
24762481
_doctor_line(WARN, "latest_run", "none")

0 commit comments

Comments
 (0)