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
5 changes: 5 additions & 0 deletions code-mower-package-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1447,6 +1447,11 @@
"source": "src/code_mower/remote_session_cli.py",
"target": "src/code_mower/remote_session_cli.py"
},
{
"kind": "core",
"source": "src/code_mower/review_authority.py",
"target": "src/code_mower/review_authority.py"
},
{
"kind": "core",
"source": "src/code_mower/reviewer_metrics.py",
Expand Down
41 changes: 41 additions & 0 deletions docs/orchestrator-prompt-pack.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,47 @@ Grok Build, Gemini CLI, Hermes CLI, or Devin, stay informational unless the
repository has already promoted that lane under docs/lane-promotion-policy.md.
```

## Optional Devin Setup Prompt

Use this only when the owner has asked for Devin. The default adoption is
Claude + Codex, and this prompt adds no Devin work to a repository that did not
select it. Role qualification and session leases stay as described in
docs/participant-qualification.md; selecting a transport grants no review or
merge authority.

```text
The owner selected Devin for OWNER/REPO.

Report the current posture first: run code-mower doctor CONFIG --profile PROFILE
--devin and read the selected transport, readiness, and next actions. Use the
same CONFIG and PROFILE the repository actually uses; a bare run inspects the
packaged starter instead. A checkout that has no code-mower.yml has no path to
name: run code-mower doctor --packaged-starter --profile PROFILE --devin, which
selects the maintained packaged starter wherever this installation keeps it, and
use --packaged-starter in place of CONFIG in the commands below. That selector
ignores cwd-local config files and keeps the profile you name, so it reports the
same posture from any directory; --easy does not, because it is a first-run
profile alias whose starter fallback depends on what the working directory
contains. Never substitute the starter for a repository configuration to shorten
a command; it inspects a different posture.

To change transports, preview the selection with code-mower init CONFIG
--profile PROFILE --set-transport devin=devin_api_v3 --dry-run, then stage it
with --apply --output-dir .code-mower.generated. Staging writes only that review
tree: the active posture keeps reporting the installed configuration until the
generated files are reviewed and installed through the normal setup PR. Rerun
the doctor command above afterwards to confirm the switch.

If the repository still carries .github/workflows/devin-audit-bridge.yml or
.github/workflows/devin-audit-labeler.yml, report them as superseded by the
maintained Sessions API v3 transport and propose removing exactly those files in
the same reviewed PR. Do not delete or rewrite repository-owned workflow files
yourself.

Do not set up owner credentials, do not start paid sessions, and do not treat an
ordinary CLI login as campaign readiness.
```

## Owner Status Prompt

Use this when asking an orchestrator for a compact operating snapshot.
Expand Down
111 changes: 108 additions & 3 deletions src/code_mower/claude_audit_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Any, Dict, List, Optional, Tuple

from code_mower import context_audit, context_delivery, context_review
from code_mower.review_authority import AuthorityRequest as ReviewAuthorityRequest

if __package__ in {None, "", "tools"}:
try:
Expand Down Expand Up @@ -248,6 +249,10 @@ class ClaudeAuditConfig:
include_decision_context: bool = True
decision_authorities: Tuple[str, ...] = ()
merge_authority: bool = True
# When set, `merge_authority` above is only the fail-closed value used until
# `audit_pr()` resolves the posture against the base revision it fetched.
# Direct callers that decided authority themselves leave this unset.
authority_request: Optional[ReviewAuthorityRequest] = None
calibration_badge: str = ""
context_revision: Optional[str] = None
context_state_dir: Optional[Path] = None
Expand Down Expand Up @@ -297,6 +302,11 @@ class DiffContext:
full_diff_bytes: int
included_diff_bytes: int
adaptive_expanded: bool = False
# The commit the base ref resolved to once fetched, which is the revision
# this diff was taken against. Defaulted so historical fixtures and direct
# constructions keep working; an empty value means "not recorded", not
# "the working tree".
fetched_base_ref: str = ""

def __iter__(self):
"""Preserve the historical `(stat, diff, truncated)` unpacking API."""
Expand Down Expand Up @@ -876,6 +886,52 @@ def _run_git_limited(
return _decode_limited_diff(chunks, truncated=truncated), observed_bytes, truncated


def _resolve_fetched_authority(
config: ClaudeAuditConfig, local_repo: Path, base_revision: str
) -> ClaudeAuditConfig:
"""Return `config` with the posture resolved against the fetched base.

The wrapper cannot decide authority before the audit runs: the local base ref
may be stale or missing until the diff context fetches it, so a posture
computed then would describe the policy the fetch replaced — keeping
merge-authority wording through a repository demotion, or reporting an
unavailable base that is simply not fetched yet. The review diffs against the
fetched revision, so the rendered header is resolved against that same one.

Callers that decided authority themselves pass no request and are returned
unchanged, which keeps direct `ClaudeAuditConfig` use and fixtures working.
"""
request = config.authority_request
if request is None:
return config
posture = request.resolve(
repo_root=local_repo, base_ref=base_revision or config.base_ref
)
print(
" review authority: "
f"{posture['label']} ({posture['policy_source']}/{posture['reason']})",
file=sys.stderr,
flush=True,
)
return replace(config, merge_authority=posture["merge_authority"])


class _FetchedHeadMismatchWithBase(FetchedHeadMismatch):
"""A force-push race that still knows which base revision was fetched.

The base is fetched before the head mismatch is detected, so the stale
notice can be rendered against that same revision. Subclassing keeps every
existing `except FetchedHeadMismatch` handler and the shared exception
contract unchanged.
"""

def __init__(
self, expected_sha: str, actual_sha: str, fetched_base_ref: str
) -> None:
super().__init__(expected_sha, actual_sha)
self.fetched_base_ref = fetched_base_ref


def _build_diff_context(
local_repo: Path,
pr_number: int,
Expand Down Expand Up @@ -905,7 +961,9 @@ def _build_diff_context(
expected_head_sha=expected_head_sha,
)
if fetched_head_ref.lower() != expected_head_sha.lower():
raise FetchedHeadMismatch(expected_head_sha, fetched_head_ref)
raise _FetchedHeadMismatchWithBase(
expected_head_sha, fetched_head_ref, fetched_base_ref
)
diff_range = f"{fetched_base_ref}...{fetched_head_ref}"
stat = _run_git(local_repo, ["diff", "--stat", "--find-renames", diff_range])
changed_files_text = _run_git(
Expand Down Expand Up @@ -935,6 +993,7 @@ def _build_diff_context(
full_diff_bytes=full_diff_bytes,
included_diff_bytes=len(included_diff.encode("utf-8")),
adaptive_expanded=adaptive_expanded,
fetched_base_ref=fetched_base_ref,
)


Expand Down Expand Up @@ -1349,6 +1408,13 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi
config.max_diff_hard_limit_bytes,
)
except FetchedHeadMismatch as exc:
# The base was fetched before the head mismatch was detected, so the
# stale notice still renders the posture of the refreshed base.
config = replace(
config,
base_ref=getattr(exc, "fetched_base_ref", "") or config.base_ref,
)
config = _resolve_fetched_authority(config, local_repo, config.base_ref)
actions_run_id = os.environ.get("GITHUB_RUN_ID") or None
print(
f" force-push race: fetched head {exc.actual_sha[:8]} does not "
Expand Down Expand Up @@ -1447,6 +1513,17 @@ def audit_pr(config: ClaudeAuditConfig, repo: str, pr_number: int) -> ClaudeAudi
)
return result

# The diff was taken against the revision the base ref resolved to once
# fetched. Pin it onto the config, because `base_ref` was a mutable name
# until here and everything below reads it -- the rendered posture, the
# trusted-ref lookups, the review doctrine load and the review prompt's own
# base. Carrying the SHA means the comment and the diff describe the one
# revision this audit fetched even if the tracking ref moves afterwards. A
# context that recorded no revision keeps the name it was given.
config = replace(
config, base_ref=diff_context.fetched_base_ref or config.base_ref
)
config = _resolve_fetched_authority(config, local_repo, config.base_ref)
print(
f" diff budget: {diff_context.diagnostics()}",
file=sys.stderr,
Expand Down Expand Up @@ -1943,7 +2020,14 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
or _env_flag("CLAUDE_AUDIT_NO_SPEND_CAPTURE"),
help="do not append this audit run to reviewer-spend.json",
)
posture_default = _env_flag_default("CLAUDE_AUDIT_MERGE_AUTHORITY", True)
# Unset means "render the posture this repository actually configures".
# An explicit flag or env override stays authoritative, so an operator can
# still state a posture for a checkout that configures no lanes.
posture_default = (
_env_flag_default("CLAUDE_AUDIT_MERGE_AUTHORITY", True)
if os.environ.get("CLAUDE_AUDIT_MERGE_AUTHORITY") is not None
else None
)
ap.add_argument(
"--merge-authority",
dest="merge_authority",
Expand All @@ -1957,6 +2041,14 @@ def _parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
action="store_false",
help="Render audit comments as informational-only lane comments.",
)
ap.add_argument(
"--code-mower-config",
default=os.environ.get("CODE_MOWER_CONFIG") or None,
help=(
"repository configuration whose review lane decides the rendered "
"posture; defaults to code-mower.yml in the audited checkout"
),
)
ap.add_argument(
"--calibration-badge",
default=os.environ.get("CLAUDE_AUDIT_CALIBRATION_BADGE", ""),
Expand Down Expand Up @@ -2019,6 +2111,16 @@ def main(argv: Optional[List[str]] = None) -> int:
try:
repo_paths = _parse_repo_paths(args.repo_paths)
_validate_repo_path_for_wrapper(repo_paths, args.repo)
# The posture is resolved inside audit_pr, against the base revision the
# diff is taken from; the local base ref here may still be stale or
# missing. Only an explicitly selected configuration is checked now, so
# an operator typo fails before any network work rather than mid-audit.
authority_request = ReviewAuthorityRequest(
product="claude",
config_path=args.code_mower_config,
override=args.merge_authority,
)
authority_request.validate()
config = ClaudeAuditConfig(
github_token=token,
repo_paths=repo_paths,
Expand All @@ -2041,7 +2143,10 @@ def main(argv: Optional[List[str]] = None) -> int:
max_plan_context_bytes=args.max_plan_context_bytes,
max_plan_context_file_bytes=args.max_plan_context_file_bytes,
include_decision_context=not args.no_decision_context,
merge_authority=args.merge_authority,
# Fail closed until the fetched base decides: nothing rendered before
# that resolution may claim authority this run has not verified.
merge_authority=False,
authority_request=authority_request,
calibration_badge=args.calibration_badge,
)
result = audit_pr(config, args.repo, args.pr)
Expand Down
1 change: 1 addition & 0 deletions src/code_mower/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@ def _init_main(argv: list[str]) -> int:
if (
argv[:1] == ["auth"]
or _has_flag(argv, "--easy")
or _has_flag(argv, "--packaged-starter")
or _has_positional_config(argv, options_with_values)
):
return code_mower_init.main(argv)
Expand Down
Loading
Loading