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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ for a real repository. It checks your runtime, GitHub setup, provider CLIs,
token posture, optional cloud setup, private-repo Actions cost traps, and
first-run adoption gaps. `--preflight` remains the compatibility preset for
older scripts; `--adoption` adds explicit repo targeting and setup guidance.
Use `--hosted-builders` or `--orchestrator-only` when the current machine is
observing/coordinating lanes and will not run local Codex or Claude wrappers.

Example, shortened:

Expand Down
6 changes: 6 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ code-mower doctor --adoption --hosted-builders --repo OWNER/REPO --json
code-mower doctor --adoption --orchestrator-only --repo OWNER/REPO --json
```

In those observer/coordinator postures, missing local wrapper environment
variables and missing `DISPATCH_TOKEN` setup are surfaced as owner setup or
promotion tasks, not as proof the install is broken. Use the default
reviewer-gate posture on the machine that will actually run local audit
wrappers or unattended dispatch.

Then follow [Try Code Mower In 10 Minutes](try-in-10-minutes.md) for the first
audited PR or [Build Loop In 30 Minutes](build-loop-in-30-minutes.md) after the
reviewer gate is working.
6 changes: 4 additions & 2 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,8 @@ code-mower doctor --adoption --repo OWNER/REPO --json
```

`doctor --adoption` is the recommended early-adopter preset for GitHub auth,
Python/runtime checks, provider CLI probes, private-repo caveats, Actions cost
Python/runtime checks, provider CLI probes for machines that run local lanes,
private-repo caveats, Actions cost
diagnostics, branch-protection source, repository auto-merge, human automation
token metadata, optional cloud-token setup, and first-run setup gaps such as
starter config or missing owner/trusted-author posture. Use `--strict` only
Expand All @@ -297,7 +298,8 @@ when warnings should fail a bootstrap job. For auth-specific doctor failures, se
If this machine observes or dispatches hosted builders but does not run local
Codex/Claude audits, use `--hosted-builders` or `--orchestrator-only` with
`doctor --adoption`; those postures keep GitHub, cloud, setup, and privacy
checks visible while marking local CLI probes skipped.
checks visible while marking local CLI probes skipped and treating local wrapper
env gaps as setup tasks for the machine that will execute those lanes.

When setup is visible enough to start work, use one command to check live lane
state:
Expand Down
7 changes: 6 additions & 1 deletion docs/try-in-10-minutes.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ adopters need:

- recommended profile selection;
- Python/runtime checks;
- local provider CLI discovery and smoke probes;
- local provider CLI discovery and smoke probes when this machine will run
local lanes;
- stale terminal-label hygiene for merge-authority reviewer lanes;
- GitHub repository visibility, permissions, branch protection, and Actions
cost diagnostics; and
Expand All @@ -96,6 +97,10 @@ Warnings are setup guidance. They are only fatal when you pass `--strict`. In
JSON mode, check the top-level `run_plan` field first. It tells you whether the
preflight included GitHub and optional cloud checks before you inspect
individual provider warnings.
Use `--hosted-builders` or `--orchestrator-only` when this machine observes or
coordinates lanes without running Codex/Claude local audit wrappers; those
postures skip local-wrapper probes and keep missing local wrapper env vars out
of the warning list.

For merge-authority lanes such as Codex or Claude audit, look for
`provider.review_hygiene`. It should pass for lanes that can satisfy the merge
Expand Down
42 changes: 41 additions & 1 deletion src/code_mower/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@
normalize_repo_slug = _doctor_checks.normalize_repo_slug


def _source_repo_uses_starter_config(cwd: Path, config_path: Path) -> bool:
"""Return true when doctor is running from Code Mower's source tree."""

try:
config_path.resolve().relative_to(cwd.resolve())
except ValueError:
return False
return (
config_path.name == "code-mower.example.yml"
and (cwd / "pyproject.toml").is_file()
and (cwd / "src" / "code_mower" / "templates" / "code-mower.example.yml").is_file()
)


def _doctor_config_source_label(
*,
config_arg: str,
config_path: Path,
easy: bool,
cwd: Path | None = None,
) -> str:
"""Classify the config source for adoption-facing doctor output."""

cwd = cwd or Path.cwd()
if config_arg != "code-mower.yml":
return "explicit_config"
if config_path.name == "code-mower.example.yml" and easy:
if _source_repo_uses_starter_config(cwd, config_path):
return "source_tree_starter"
return "packaged_starter"
return "repository_config"


_DOCTOR_COMPAT_EXPORTS = (
DEFAULT_CLOUD_TOKEN_DIR,
DEFAULT_CLOUD_TOKEN_ENV,
Expand All @@ -68,6 +101,7 @@
resolve_doctor_config_path_for_script,
resolve_doctor_provider_templates_path,
_token_file_mentions_cloud_token,
_doctor_config_source_label,
)


Expand Down Expand Up @@ -202,12 +236,18 @@ def main(argv: Sequence[str] | None = None) -> int:
repo_slug = detect_repo_slug(Path.cwd())
repo_source = "git_remote" if repo_slug else ""
provider_templates_path = resolve_doctor_provider_templates_path(args.provider_templates)
config_path = resolve_doctor_config_path(args.config, easy=args.easy)
report = run_doctor(
config_path=resolve_doctor_config_path(args.config, easy=args.easy),
config_path=config_path,
provider_templates_path=provider_templates_path,
profile=args.profile,
repo_slug=repo_slug,
repo_source=repo_source,
config_source=_doctor_config_source_label(
config_arg=args.config,
config_path=config_path,
easy=args.easy,
),
adoption=args.adoption,
adoption_posture=args.adoption_posture,
probe_runtime=args.probe_runtime,
Expand Down
32 changes: 20 additions & 12 deletions src/code_mower/doctor_checks/adoption.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ def check_adoption_setup(
repo_slug: str,
repo_source: str,
using_packaged_example: bool,
config_source: str = "",
) -> tuple[DoctorCheck, ...]:
"""Return first-run adoption posture checks."""

Expand Down Expand Up @@ -144,17 +145,28 @@ def check_adoption_setup(
)

repositories = _configured_repositories(config)
source = config_source or (
"packaged_starter" if using_packaged_example else "repository_config"
)
source_messages = {
"explicit_config": "using explicit Code Mower config",
"packaged_starter": "using packaged starter config for adoption checks",
"repository_config": "using repository Code Mower config",
"source_tree_starter": "using source-tree starter config for adoption checks",
}
detail = {
"config_path": str(config_path),
"config_source": source,
"configured_repositories": repositories,
"effective_repository": repo_slug,
}
if using_packaged_example:
checks.append(
DoctorCheck(
name="doctor.adoption.config_source",
status=STATUS_WARN,
message="using packaged starter config for adoption checks",
detail={
"config_path": str(config_path),
"configured_repositories": repositories,
"effective_repository": repo_slug,
},
message=source_messages.get(source, source_messages["packaged_starter"]),
detail=detail,
remediation=(
"Run `code-mower init --easy --apply`, review the generated "
"setup, and commit an edited code-mower.yml before relying "
Expand All @@ -167,12 +179,8 @@ def check_adoption_setup(
DoctorCheck(
name="doctor.adoption.config_source",
status=STATUS_PASS,
message="using repository Code Mower config",
detail={
"config_path": str(config_path),
"configured_repositories": repositories,
"effective_repository": repo_slug,
},
message=source_messages.get(source, source_messages["repository_config"]),
detail=detail,
)
)

Expand Down
2 changes: 2 additions & 0 deletions src/code_mower/doctor_checks/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"audit-label-cleanup",
"devin-audit-bridge",
)
OBSERVER_ADOPTION_POSTURES = frozenset({"hosted-builders", "orchestrator-only"})

__all__ = [
"ACTIONS_BILLING_BLOCK_PATTERNS",
Expand All @@ -76,6 +77,7 @@
"DoctorCheck",
"MAX_ACTIONS_FAILED_JOBS_TO_INSPECT",
"MAX_ACTIONS_FAILED_RUNS_TO_INSPECT",
"OBSERVER_ADOPTION_POSTURES",
"STATUS_FAIL",
"STATUS_PASS",
"STATUS_SKIP",
Expand Down
2 changes: 2 additions & 0 deletions src/code_mower/doctor_checks/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def check_github_setup(
lanes: Sequence[tuple[str, Mapping[str, Any]]],
http_timeout: int,
actions_cost_sample: int = ACTIONS_COST_SAMPLE_DEFAULT,
adoption_posture: str = "reviewer-gate",
) -> list[DoctorCheck]:
checks: list[DoctorCheck] = []
gh_path = shutil.which("gh")
Expand Down Expand Up @@ -142,6 +143,7 @@ def check_github_setup(
config=config,
lanes=lanes,
http_timeout=http_timeout,
adoption_posture=adoption_posture,
)
)
if has_merge_authority:
Expand Down
46 changes: 39 additions & 7 deletions src/code_mower/doctor_checks/github_human_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@
from datetime import UTC, date, datetime
from typing import Any, Mapping, Sequence

from .common import DoctorCheck, STATUS_FAIL, STATUS_PASS, STATUS_SKIP, STATUS_WARN
from .common import (
OBSERVER_ADOPTION_POSTURES,
DoctorCheck,
STATUS_FAIL,
STATUS_PASS,
STATUS_SKIP,
STATUS_WARN,
)
from .github_api import _github_api_json

DEFAULT_HUMAN_TOKEN_SECRET = "DISPATCH_TOKEN"
Expand Down Expand Up @@ -93,13 +100,26 @@ def _is_expiry_placeholder(value: str) -> bool:
return value.strip().upper() in EXPIRY_PLACEHOLDER_VALUES


def _blocking_status_for_posture(adoption_posture: str) -> str:
return STATUS_WARN if adoption_posture in OBSERVER_ADOPTION_POSTURES else STATUS_FAIL


def _token_readiness_context(adoption_posture: str) -> str:
if adoption_posture == "hosted-builders":
return "hosted-builder observer posture"
if adoption_posture == "orchestrator-only":
return "orchestrator-only posture"
return "reviewer-gate posture"


def check_human_automation_token(
*,
gh_path: str,
slug: str,
config: Mapping[str, Any],
lanes: Sequence[tuple[str, Mapping[str, Any]]],
http_timeout: int,
adoption_posture: str = "reviewer-gate",
now: datetime | None = None,
) -> DoctorCheck:
token = human_automation_token_config(config)
Expand All @@ -110,6 +130,7 @@ def check_human_automation_token(
"secret": secret_name,
"expires_var": expires_var,
"required": human_automation_token_required(config, lanes),
"adoption_posture": adoption_posture,
}
if not detail["required"]:
return DoctorCheck(
Expand All @@ -125,15 +146,24 @@ def check_human_automation_token(
http_timeout=http_timeout,
)
if secret_payload is None:
status = _blocking_status_for_posture(adoption_posture)
return DoctorCheck(
name="github.human_automation_token",
status=STATUS_FAIL,
message=f"{slug} is missing the {secret_name} human automation token secret",
status=status,
message=(
f"{slug} is missing the {secret_name} human automation token secret"
+ (
f" for {_token_readiness_context(adoption_posture)}"
if status == STATUS_WARN
else ""
)
),
detail={**detail, "secret_check": secret_detail},
remediation=(
f"Create one human-owned fine-grained PAT secret with "
f"`gh secret set {secret_name}`. Grant repository Contents read, "
"Issues read/write, and Pull requests read/write."
"Issues read/write, and Pull requests read/write before relying "
"on unattended dispatch, labels, or fix-round mentions."
),
)

Expand All @@ -143,9 +173,10 @@ def check_human_automation_token(
http_timeout=http_timeout,
)
if variable_payload is None:
status = _blocking_status_for_posture(adoption_posture)
return DoctorCheck(
name="github.human_automation_token",
status=STATUS_FAIL,
status=status,
message=f"{slug} is missing the {expires_var} human token expiry variable",
detail={
**detail,
Expand Down Expand Up @@ -188,9 +219,10 @@ def check_human_automation_token(
)
expiry = _parse_expiry(expiry_text)
if expiry is None:
status = _blocking_status_for_posture(adoption_posture)
return DoctorCheck(
name="github.human_automation_token",
status=STATUS_FAIL,
status=status,
message=f"{slug} has an invalid {expires_var} value",
detail={**detail, "expires_at": expiry_text},
remediation=(
Expand All @@ -203,7 +235,7 @@ def check_human_automation_token(
days_remaining = (expiry - today).days
status = STATUS_PASS
if days_remaining < 0:
status = STATUS_FAIL
status = _blocking_status_for_posture(adoption_posture)
elif days_remaining <= EXPIRY_WARNING_DAYS:
status = STATUS_WARN

Expand Down
13 changes: 9 additions & 4 deletions src/code_mower/doctor_checks/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from typing import Any, Mapping

from .common import (
OBSERVER_ADOPTION_POSTURES,
DoctorCheck,
STATUS_FAIL,
STATUS_PASS,
Expand Down Expand Up @@ -38,7 +39,7 @@
"selected_lanes",
]

LOCAL_CLI_SKIP_POSTURES = {"hosted-builders", "orchestrator-only"}
LOCAL_CLI_SKIP_POSTURES = OBSERVER_ADOPTION_POSTURES


def selected_lanes(
Expand Down Expand Up @@ -185,11 +186,15 @@ def check_lane_runtime(
repo_root=repo_root,
)
]
checks.extend(check_token_env(lane_id, lane))
checks.extend(check_required_env(lane_id, lane))
driver = str(lane.get("driver", ""))
skip_local_cli_runtime = (
driver == "local_cli" and adoption_posture in LOCAL_CLI_SKIP_POSTURES
)
if not skip_local_cli_runtime:
checks.extend(check_token_env(lane_id, lane))
checks.extend(check_required_env(lane_id, lane))
if driver == "local_cli":
if adoption_posture in LOCAL_CLI_SKIP_POSTURES:
if skip_local_cli_runtime:
checks.extend(
_skip_local_cli_checks(
lane_id,
Expand Down
3 changes: 3 additions & 0 deletions src/code_mower/doctor_checks/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def run_doctor(
profile: str | None,
repo_slug: str = "",
repo_source: str = "",
config_source: str = "",
adoption: bool = False,
adoption_posture: str = "reviewer-gate",
probe_runtime: bool = False,
Expand Down Expand Up @@ -114,6 +115,7 @@ def run_doctor(
adoption=adoption,
repo_slug=repo_slug,
repo_source=repo_source,
config_source=config_source,
using_packaged_example=using_packaged_example,
)
)
Expand Down Expand Up @@ -223,6 +225,7 @@ def run_doctor(
lanes=effective_lanes,
http_timeout=http_timeout,
actions_cost_sample=actions_cost_sample,
adoption_posture=adoption_posture,
)
)

Expand Down
Loading
Loading