doctor.py python
163 lines 6.6 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 12 hours ago
1 """Workspace doctor diagnostics (§MR.7.1 / §MR.6.5)."""
2
3 from __future__ import annotations
4
5 import os
6 from pathlib import Path
7
8 from adapters.config import OverseerConfig
9 from tools.workspace.check_next import resolve_member_boards
10 from tools.workspace.manifest import discover_manifest
11 from tools.workspace.next_extract import extract_next_blocks, legacy_forbidden_archived_headings
12 from tools.workspace.types import DoctorFinding, DoctorReport, WorkspaceLoadError
13
14
15 def run_doctor(
16 config: OverseerConfig,
17 repo_root: Path,
18 *,
19 environ: dict[str, str] | None = None,
20 home: Path | None = None,
21 invoke_git: bool = True,
22 ) -> DoctorReport:
23 """Collect diagnostics; never merges/pushes. Regime-aware git/gh skip for muse-only."""
24 if config.workspace is None:
25 return DoctorReport(configured=False, findings=(), ok=True)
26
27 findings: list[DoctorFinding] = []
28 try:
29 manifest = discover_manifest(config, repo_root, environ=environ, home=home)
30 except WorkspaceLoadError as exc:
31 return DoctorReport(
32 configured=True,
33 findings=(DoctorFinding(code="error", message=str(exc)),),
34 ok=False,
35 )
36 assert manifest is not None
37
38 if manifest.manifest_source == "home_index":
39 findings.append(
40 DoctorFinding(
41 code="manifest_source_home_index",
42 message="manifest loaded from ~/.overseer/workspaces (last resort)",
43 path=str(manifest.source_path),
44 )
45 )
46
47 boards = resolve_member_boards(manifest, environ=environ, home=home)
48 for board in boards:
49 if board.member_status == "absent":
50 findings.append(
51 DoctorFinding(
52 code="member_absent",
53 message=f"optional member {board.member_id} absent",
54 member_id=board.member_id,
55 )
56 )
57 continue
58 if board.member_status == "missing_required":
59 findings.append(
60 DoctorFinding(
61 code="missing_member",
62 message=f"required member {board.member_id} missing",
63 member_id=board.member_id,
64 )
65 )
66 continue
67 if board.board_name_violation:
68 findings.append(
69 DoctorFinding(
70 code="board_name_violation",
71 message=(
72 f"board_name_violation: {board.member_id} "
73 f"handover={board.handover_basename!r} "
74 f"roadmap={board.roadmap_basename!r} "
75 f"(expected {{REPO_SLUG}}-OVERSEER-HANDOVER.md / {{REPO_SLUG}}-ROADMAP.md)"
76 ),
77 member_id=board.member_id,
78 path=str(board.handover_path) if board.handover_path else None,
79 )
80 )
81
82 if board.root is None:
83 continue
84
85 # Regime mismatch advisory
86 member_row = manifest.member(board.member_id)
87 if member_row and member_row.regime and board.regime and member_row.regime != board.regime:
88 findings.append(
89 DoctorFinding(
90 code="regime_mismatch",
91 message=(
92 f"regime_mismatch: manifest {member_row.regime!r} != "
93 f"member config {board.regime!r}"
94 ),
95 member_id=board.member_id,
96 )
97 )
98
99 # muse-only: never invoke git/gh (S5)
100 regime = board.regime or (member_row.regime if member_row else None)
101 if regime == "muse-only":
102 # Explicitly skip git — record that we honored the guard when asked.
103 if invoke_git:
104 findings.append(
105 DoctorFinding(
106 code="muse_only_skip_git",
107 message=f"skipped git/gh for muse-only member {board.member_id}",
108 member_id=board.member_id,
109 )
110 )
111 elif invoke_git and regime == "muse+git-mirror" and board.root is not None:
112 # Advisory muse≠git only when .muse and .git both present — no network.
113 muse_head = board.root / ".muse" / "HEAD"
114 git_dir = board.root / ".git"
115 if muse_head.is_file() and git_dir.exists():
116 try:
117 muse_sha = muse_head.read_text(encoding="utf-8").strip().split()[-1]
118 except OSError:
119 muse_sha = ""
120 git_head = board.root / ".git" / "HEAD"
121 # Soft note only — do not shell git for muse-only; for muse+git we may read files.
122 if muse_sha and git_head.is_file():
123 findings.append(
124 DoctorFinding(
125 code="muse_sync_advisory",
126 message=f"muse HEAD present for {board.member_id}; use ok status for muse_sync gate",
127 member_id=board.member_id,
128 )
129 )
130
131 if board.handover_path and board.handover_path.is_file():
132 text = board.handover_path.read_text(encoding="utf-8")
133 if legacy_forbidden_archived_headings(text):
134 findings.append(
135 DoctorFinding(
136 code="forbidden_archived_next",
137 message=f"forbidden ## NEXT SESSION — … archived … in {board.handover_path}",
138 member_id=board.member_id,
139 path=str(board.handover_path),
140 )
141 )
142 blocks = extract_next_blocks(text)
143 if any(b.unmarked for b in blocks):
144 findings.append(
145 DoctorFinding(
146 code="unmarked_next",
147 message=f"unmarked ## NEXT SESSION — on {board.handover_path}",
148 member_id=board.member_id,
149 path=str(board.handover_path),
150 )
151 )
152
153 hard = {
154 "error",
155 "missing_member",
156 "board_name_violation",
157 "forbidden_archived_next",
158 }
159 # Doctor ok is advisory: board_name_violation is reported but does not make
160 # doctor exit non-zero by itself for CLI (freeze: doctor surfaces UX debt).
161 # We still set ok=False when required members missing or config error.
162 ok = not any(f.code in {"error", "missing_member"} for f in findings)
163 return DoctorReport(configured=True, findings=tuple(findings), ok=ok)
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 12 hours ago