diff --git a/.gitignore b/.gitignore index 1e3c192..a8407f6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ keys/ *.pem .env .DS_Store +deploy/complete-tech/.coven-github-private-key.pem +deploy/complete-tech/coven-github-policy.json +deploy/complete-tech/coven-github-state/ diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 5e05f32..97d04cf 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -12,7 +12,7 @@ pub const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; /// Major version of the coven-code headless execution contract this adapter /// speaks. See `docs/headless-contract.md`. Bump only on breaking changes. -pub const HEADLESS_CONTRACT_VERSION: &str = "1"; +pub const HEADLESS_CONTRACT_VERSION: &str = "2"; fn default_contract_version() -> String { HEADLESS_CONTRACT_VERSION.to_string() @@ -187,6 +187,7 @@ pub struct SessionResult { pub files_changed: Vec, pub summary: String, pub pr_body: String, + pub review: ReviewResult, pub exit_reason: Option, } @@ -205,6 +206,86 @@ pub struct CommitInfo { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewResult { + pub mode: ReviewMode, + pub evidence_status: ReviewEvidenceStatus, + pub reviewed_files: Vec, + pub supporting_files: Vec, + pub findings: Vec, + pub tests_run: Vec, + pub no_findings_reason: Option, + pub limitations: Vec, +} + +impl ReviewResult { + pub fn none() -> Self { + Self { + mode: ReviewMode::None, + evidence_status: ReviewEvidenceStatus::NotApplicable, + reviewed_files: Vec::new(), + supporting_files: Vec::new(), + findings: Vec::new(), + tests_run: Vec::new(), + no_findings_reason: None, + limitations: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewMode { + None, + PullRequest, + ReviewComment, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewEvidenceStatus { + NotApplicable, + Complete, + Partial, + Missing, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewFinding { + pub severity: ReviewSeverity, + pub file: String, + pub line: Option, + pub title: String, + pub body: String, + pub recommendation: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewSeverity { + Info, + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewTestRun { + pub command: String, + pub status: ReviewTestStatus, + pub output_summary: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewTestStatus { + Passed, + Failed, + NotRun, + Unknown, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ExitReason { diff --git a/crates/github/src/tasks.rs b/crates/github/src/tasks.rs index 4b1aff8..b35bf6c 100644 --- a/crates/github/src/tasks.rs +++ b/crates/github/src/tasks.rs @@ -191,6 +191,7 @@ mod tests { files_changed: vec![], summary: "Done".to_string(), pr_body: "Body".to_string(), + review: crate::ReviewResult::none(), exit_reason: None, }, Some(9), diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index c26f7da..bc8372c 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -625,7 +625,7 @@ fn task_issue_number(kind: &TaskKind) -> Option { #[cfg(test)] mod disposition_tests { use super::*; - use coven_github_api::{CommitInfo, HEADLESS_CONTRACT_VERSION}; + use coven_github_api::{CommitInfo, ReviewResult, HEADLESS_CONTRACT_VERSION}; use coven_github_config::{GitHubAppConfig, ServerConfig, WorkerConfig}; use std::path::PathBuf; @@ -643,6 +643,7 @@ mod disposition_tests { files_changed: vec![], summary: "summary".to_string(), pr_body: "body".to_string(), + review: ReviewResult::none(), exit_reason: None, } } @@ -828,7 +829,7 @@ mod process_tests { /// A minimal contract-valid result.json with the given status/exit_reason. fn result_json(status: &str, exit_reason: &str) -> String { format!( - r#"{{"contract_version":"1","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","exit_reason":{exit_reason}}}"# + r#"{{"contract_version":"2","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]}},"exit_reason":{exit_reason}}}"# ) } diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index b5cfd91..45387e1 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -5,7 +5,10 @@ //! If these fail, either the runtime contract drifted or the docs are stale โ€” //! fix one of them, do not just bless the test. -use coven_github_api::{ExitReason, SessionResult, SessionStatus, HEADLESS_CONTRACT_VERSION}; +use coven_github_api::{ + ExitReason, ReviewEvidenceStatus, ReviewMode, SessionResult, SessionStatus, + HEADLESS_CONTRACT_VERSION, +}; use coven_github_worker::brief::SessionBrief; fn fixture(name: &str) -> String { @@ -43,6 +46,11 @@ fn golden_result_deserializes_into_adapter_type() { assert_eq!(result.contract_version, HEADLESS_CONTRACT_VERSION); assert_eq!(result.status, SessionStatus::Success); + assert_eq!(result.review.mode, ReviewMode::None); + assert_eq!( + result.review.evidence_status, + ReviewEvidenceStatus::NotApplicable + ); assert_eq!(result.branch.as_deref(), Some("cody/fix-issue-42")); assert_eq!(result.commits.len(), 1); assert_eq!( @@ -63,6 +71,16 @@ fn result_without_contract_version_defaults_to_current() { "files_changed": [], "summary": "Could not reproduce.", "pr_body": "", + "review": { + "mode": "none", + "evidence_status": "not_applicable", + "reviewed_files": [], + "supporting_files": [], + "findings": [], + "tests_run": [], + "no_findings_reason": null, + "limitations": [] + }, "exit_reason": "ambiguous_spec" }"#; let result: SessionResult = serde_json::from_str(raw).expect("legacy result must parse"); diff --git a/deploy/complete-tech/README.md b/deploy/complete-tech/README.md new file mode 100644 index 0000000..5c695ae --- /dev/null +++ b/deploy/complete-tech/README.md @@ -0,0 +1,36 @@ +# CompleteTech hosted dogfood adapter + +This directory tracks the Python adapter currently deployed for the +CompleteTech-hosted dogfood GitHub App at `webhook.complete.tech`. + +The adapter is intentionally deployment-specific. It is not the canonical Rust +worker implementation; it exists so hosted dogfood behavior can be reviewed, +reproduced, and changed through PRs instead of invisible server edits. + +## Files + +- `coven_github_adapter.py` - webhook handler, task runner, PR evidence capture, + Codex-backed headless runtime invocation, and dogfood comment publisher. + +## Runtime inputs + +The deployment expects secrets and mutable state to be supplied outside git: + +- `GITHUB_APP_PRIVATE_KEY_PATH` or `.coven-github-private-key.pem` +- `GITHUB_APP_ID` +- `COVEN_GITHUB_STATE_DIR` +- `COVEN_GITHUB_POLICY_PATH` +- `COVEN_CODE_BIN` +- Codex OAuth tokens under the deployed account's `.coven-code` directory + +Do not commit private keys, webhook secrets, OAuth tokens, generated task state, +workspaces, or attempt artifacts. + +## Current dogfood behavior + +- Emits headless contract v2 session briefs. +- Captures PR checkout metadata and changed-file patches before invoking + `coven-code`. +- Publishes visible structured review evidence, including `reviewed_files`, + `supporting_files`, findings, test evidence, no-findings rationale, and + limitations. diff --git a/deploy/complete-tech/coven_github_adapter.py b/deploy/complete-tech/coven_github_adapter.py new file mode 100644 index 0000000..2d1b6ef --- /dev/null +++ b/deploy/complete-tech/coven_github_adapter.py @@ -0,0 +1,955 @@ +import base64 +import hashlib +import json +import os +import subprocess +import tempfile +import time +import traceback +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +ROOT_DIR = Path(__file__).resolve().parent +STATE_DIR = Path(os.environ.get("COVEN_GITHUB_STATE_DIR", ROOT_DIR / "coven-github-state")) +DELIVERIES_DIR = STATE_DIR / "deliveries" +TASKS_DIR = STATE_DIR / "tasks" +WORKSPACES_DIR = STATE_DIR / "workspaces" +ATTEMPTS_DIR = STATE_DIR / "attempts" +POLICY_PATH = Path(os.environ.get("COVEN_GITHUB_POLICY_PATH", ROOT_DIR / "coven-github-policy.json")) +PRIVATE_KEY_PATH = Path( + os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH", ROOT_DIR / ".coven-github-private-key.pem") +) +APP_ID = os.environ.get("GITHUB_APP_ID", "4224630").strip() +COVEN_CODE_BIN = os.environ.get("COVEN_CODE_BIN", "coven-code").strip() or "coven-code" +COVEN_CODE_MODEL = os.environ.get("COVEN_CODE_MODEL", "gpt-5.5").strip() + + +def account_home(): + try: + import pwd + + return Path(pwd.getpwuid(os.getuid()).pw_dir) + except Exception: + return Path.home() + + +def configured_codex_tokens_path(): + configured = os.environ.get("COVEN_CODE_CODEX_TOKENS_PATH", "").strip() + if configured: + return Path(configured).expanduser() + return account_home() / ".coven-code" / "codex_tokens.json" + + +CODEX_TOKENS_PATH = configured_codex_tokens_path() + +for directory in (DELIVERIES_DIR, TASKS_DIR, WORKSPACES_DIR, ATTEMPTS_DIR): + directory.mkdir(parents=True, exist_ok=True) + + +DEFAULT_POLICY = { + "version": 1, + "installations": { + "144638200": { + "repositories": { + "1290307745": { + "full_name": "CompleteTech-LLC-AI-Research/coven-code", + "default_branch": "main", + "enabled_triggers": [ + "issue_mention", + "issue_label", + "pr_review_comment", + "pull_request", + "push", + ], + "trigger_labels": ["coven", "coven:fix", "coven:review"], + "bot_usernames": ["coven-github", "cody"], + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": None, + "skills": ["code-review"], + }, + "publication": {"mode": "comment"}, + } + } + } + }, +} + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def read_json(path, default): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return default + + +def write_json_atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2) + handle.write("\n") + os.replace(tmp_name, str(path)) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def b64url(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def sign_rs256(message): + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + key_data = PRIVATE_KEY_PATH.read_bytes() + private_key = serialization.load_pem_private_key(key_data, password=None) + return private_key.sign(message, padding.PKCS1v15(), hashes.SHA256()) + + +def github_app_jwt(): + now = int(time.time()) + header = {"alg": "RS256", "typ": "JWT"} + payload = {"iat": now - 60, "exp": now + 540, "iss": APP_ID} + signing_input = ( + b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) + + "." + + b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + ).encode("ascii") + return signing_input.decode("ascii") + "." + b64url(sign_rs256(signing_input)) + + +def github_request(method, url, token, body=None): + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "User-Agent": "coven-github-hosted-prototype", + "X-GitHub-Api-Version": "2022-11-28", + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise RuntimeError("GitHub API {} {} failed: {}".format(method, url, raw)) + + +def installation_token(installation_id): + app_token = github_app_jwt() + response = github_request( + "POST", + "https://api.github.com/app/installations/{}/access_tokens".format(installation_id), + app_token, + {}, + ) + token = response.get("token") + if not token: + raise RuntimeError("GitHub installation token response did not include token") + return token + + +def load_policy(): + if not POLICY_PATH.exists(): + write_json_atomic(POLICY_PATH, DEFAULT_POLICY) + return read_json(POLICY_PATH, DEFAULT_POLICY) + + +def repo_policy(payload): + policy = load_policy() + installation_id = str((payload.get("installation") or {}).get("id") or "") + repository = payload.get("repository") or {} + repo_id = str(repository.get("id") or "") + installation = (policy.get("installations") or {}).get(installation_id) or {} + repo = (installation.get("repositories") or {}).get(repo_id) + return installation_id, repo_id, repo + + +def delivery_path(delivery_id): + return DELIVERIES_DIR / (delivery_id + ".json") + + +def task_path(task_id): + return TASKS_DIR / (task_id + ".json") + + +def payload_hash(payload): + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def delivery_record(delivery_id, event_name, payload): + repository = payload.get("repository") or {} + installation = payload.get("installation") or {} + return { + "delivery_id": delivery_id, + "event": event_name, + "action": payload.get("action"), + "installation_id": installation.get("id"), + "repository_id": repository.get("id"), + "repository": repository.get("full_name"), + "payload_hash": payload_hash(payload), + "received_at": utc_now(), + "state": "received", + "issue_refs": ["OpenCoven/coven-github#2"], + } + + +def mentioned(text, policy): + normalized = (text or "").lower() + for username in policy.get("bot_usernames") or []: + if "@" + username.lower() in normalized: + return True + return False + + +def labels_include_trigger(labels, policy): + wanted = set((policy.get("trigger_labels") or [])) + for label in labels or []: + name = (label.get("name") if isinstance(label, dict) else str(label)).strip() + if name in wanted: + return True + return False + + +def build_task_from_event(event_name, delivery_id, payload, policy): + repository = payload.get("repository") or {} + installation = payload.get("installation") or {} + familiar = policy.get("familiar") or DEFAULT_POLICY["installations"]["144638200"]["repositories"]["1290307745"]["familiar"] + base = { + "task_id": delivery_id, + "delivery_id": delivery_id, + "created_at": utc_now(), + "updated_at": utc_now(), + "state": "queued", + "attempts": 0, + "installation_id": installation.get("id"), + "repository_id": repository.get("id"), + "repository": repository.get("full_name"), + "clone_url": repository.get("clone_url") + or "https://github.com/{}.git".format(repository.get("full_name")), + "default_branch": repository.get("default_branch") or policy.get("default_branch") or "main", + "familiar": familiar, + "publication": policy.get("publication") or {"mode": "record_only"}, + "issue_refs": ["OpenCoven/coven-github#2", "OpenCoven/coven-github#7"], + } + + if event_name == "issue_comment": + issue = payload.get("issue") or {} + comment = payload.get("comment") or {} + if not mentioned(comment.get("body"), policy): + return ignored(base, "issue_comment_without_mention") + if issue.get("pull_request"): + base.update( + { + "trigger": "pr_mention", + "target": { + "kind": "pull_request", + "pr_number": int(issue.get("number") or 0), + }, + "task": { + "kind": "respond_to_mention", + "issue_number": int(issue.get("number") or 0), + "comment_body": comment.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + base.update( + { + "trigger": "issue_mention", + "task": { + "kind": "respond_to_mention", + "issue_number": int(issue.get("number") or 0), + "comment_body": comment.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "pull_request_review_comment": + comment = payload.get("comment") or {} + pull_request = payload.get("pull_request") or {} + if not mentioned(comment.get("body"), policy): + return ignored(base, "pr_review_comment_without_mention") + base.update( + { + "trigger": "pr_review_comment", + "task": { + "kind": "address_review_comment", + "pr_number": int(pull_request.get("number") or 0), + "comment_body": comment.get("body") or "", + "diff_hunk": comment.get("diff_hunk"), + "path": comment.get("path"), + "line": comment.get("line"), + "side": comment.get("side"), + "commit_id": comment.get("commit_id"), + "html_url": comment.get("html_url"), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "issues": + issue = payload.get("issue") or {} + action = payload.get("action") + if action not in ("assigned", "labeled", "opened"): + return ignored(base, "unsupported_issue_action") + if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): + return ignored(base, "issue_label_not_enabled") + base.update( + { + "trigger": "issue_assigned" if action == "assigned" else "issue_mention", + "task": { + "kind": "fix_issue", + "issue_number": int(issue.get("number") or 0), + "issue_title": issue.get("title") or "", + "issue_body": issue.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "pull_request": + pull_request = payload.get("pull_request") or {} + base.update( + { + "state": "ignored", + "ignored_reason": "pull_request_review_task_not_in_headless_contract_v1", + "trigger": "pull_request", + "target": { + "action": payload.get("action"), + "number": pull_request.get("number"), + "head_sha": (pull_request.get("head") or {}).get("sha"), + "head_ref": (pull_request.get("head") or {}).get("ref"), + "base_ref": (pull_request.get("base") or {}).get("ref"), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], + } + ) + return base + + if event_name == "push": + base.update( + { + "state": "ignored", + "ignored_reason": "push_review_task_not_in_headless_contract_v1", + "trigger": "push", + "target": { + "ref": payload.get("ref"), + "before": payload.get("before"), + "after": payload.get("after"), + "commit_count": len(payload.get("commits") or []), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], + } + ) + return base + + return ignored(base, "unsupported_event") + + +def ignored(base, reason): + base["state"] = "ignored" + base["ignored_reason"] = reason + return base + + +def route_delivery(event_name, delivery_id, payload, debug): + delivery_file = delivery_path(delivery_id) + if delivery_file.exists(): + existing = read_json(delivery_file, {}) + return { + "ok": True, + "action": "duplicate_ignored", + "delivery_id": delivery_id, + "task_id": existing.get("task_id"), + "state": existing.get("state"), + } + + delivery = delivery_record(delivery_id, event_name, payload) + installation_id, repo_id, policy = repo_policy(payload) + if not policy: + delivery["state"] = "ignored" + delivery["routing_result"] = "no_policy_for_installation_repo" + delivery["installation_id"] = installation_id or delivery.get("installation_id") + delivery["repository_id"] = repo_id or delivery.get("repository_id") + write_json_atomic(delivery_file, delivery) + return { + "ok": True, + "action": "ignored", + "delivery_id": delivery_id, + "reason": "no_policy_for_installation_repo", + } + + task = build_task_from_event(event_name, delivery_id, payload, policy) + task["policy_snapshot"] = { + "enabled_triggers": policy.get("enabled_triggers") or [], + "publication": policy.get("publication") or {"mode": "record_only"}, + } + write_json_atomic(task_path(task["task_id"]), task) + + delivery["task_id"] = task["task_id"] + delivery["state"] = task["state"] + delivery["routing_result"] = task.get("ignored_reason") or "queued" + write_json_atomic(delivery_file, delivery) + + if task["state"] == "queued": + try: + run_task(task["task_id"], debug) + except Exception: + debug("COVEN GITHUB TASK RUN FAIL task_id={} {}".format(task["task_id"], traceback.format_exc())) + + return { + "ok": True, + "action": "accepted" if task["state"] != "ignored" else "ignored", + "delivery_id": delivery_id, + "task_id": task["task_id"], + "state": read_json(task_path(task["task_id"]), task).get("state"), + "reason": task.get("ignored_reason"), + "queued": task["state"] == "queued", + } + + +def run_command(args, cwd=None, env=None, timeout=300): + proc = subprocess.run( + args, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + text=True, + ) + return { + "args": args, + "returncode": proc.returncode, + "stdout": proc.stdout[-8000:], + "stderr": proc.stderr[-8000:], + } + + +def write_askpass(work_dir): + script = work_dir / "git-askpass.sh" + script.write_text("#!/bin/sh\nprintf '%s\\n' \"$COVEN_GIT_TOKEN\"\n", encoding="utf-8") + script.chmod(0o700) + return script + + +def session_brief(task, workspace, review_context=None): + owner, name = (task["repository"] or "/").split("/", 1) + brief = { + "contract_version": "2", + "trigger": task["trigger"], + "repo": { + "owner": owner, + "name": name, + "clone_url": task["clone_url"], + "default_branch": task["default_branch"], + }, + "task": task["task"], + "familiar": task["familiar"], + "workspace": {"root": str(workspace)}, + } + if review_context: + brief["review_context"] = review_context + brief["audit_instruction"] = ( + "This run is evidence-backed. Review the supplied PR metadata and " + "changed-file patches in review_context before responding. Cite the " + "specific changed files you inspected in the result summary." + ) + return brief + + +def run_task(task_id, debug): + path = task_path(task_id) + task = read_json(path, {}) + if task.get("state") != "queued": + return task + + task["state"] = "running" + task["attempts"] = int(task.get("attempts") or 0) + 1 + task["updated_at"] = utc_now() + write_json_atomic(path, task) + + attempt_dir = ATTEMPTS_DIR / task_id / str(task["attempts"]) + attempt_dir.mkdir(parents=True, exist_ok=True) + workspace = WORKSPACES_DIR / task_id / "repo" + + try: + token = installation_token(task["installation_id"]) + askpass = write_askpass(attempt_dir) + env = os.environ.copy() + env["GIT_ASKPASS"] = str(askpass) + env["GIT_TERMINAL_PROMPT"] = "0" + env["COVEN_GIT_TOKEN"] = token + env["COVEN_CODE_PROVIDER"] = "codex" + env["COVEN_CODE_HOSTED_REVIEW"] = "1" + env["HOME"] = str(CODEX_TOKENS_PATH.parent.parent) + codex_access_token = load_codex_access_token() + if not codex_access_token: + return fail_task( + path, + task, + "codex_auth_missing", + "Missing Codex access token at {}".format(CODEX_TOKENS_PATH), + ) + env["OPENAI_API_KEY"] = codex_access_token + + if not workspace.exists(): + clone = run_command( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + task["default_branch"], + task["clone_url"], + str(workspace), + ], + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "clone.json", redacted_command_result(clone)) + if clone["returncode"] != 0: + return fail_task(path, task, "clone_failed", clone["stderr"]) + + review_context = prepare_review_context(task, workspace, token, env, attempt_dir) + if review_context: + review_context_path = attempt_dir / "review-context.json" + write_json_atomic(review_context_path, review_context) + task["review_context_path"] = str(review_context_path) + task["review_context_sha256"] = file_sha256(review_context_path) + task["review_evidence"] = review_evidence(review_context, review_context_path, task) + write_json_atomic(path, task) + + brief_path = attempt_dir / "session-brief.json" + result_path = attempt_dir / "result.json" + write_json_atomic(brief_path, session_brief(task, workspace, review_context)) + task["session_brief_path"] = str(brief_path) + task["session_brief_sha256"] = file_sha256(brief_path) + write_json_atomic(path, task) + + if not command_exists(COVEN_CODE_BIN): + return fail_task( + path, + task, + "runtime_missing", + "COVEN_CODE_BIN is not available on the host: {}".format(COVEN_CODE_BIN), + ) + + run = run_command( + [ + COVEN_CODE_BIN, + "--headless", + "--hosted-review", + "--provider", + "codex", + "--model", + COVEN_CODE_MODEL, + "--context", + str(brief_path), + "--output", + str(result_path), + ], + cwd=str(workspace), + env=env, + timeout=1800, + ) + write_json_atomic(attempt_dir / "run.json", redacted_command_result(run)) + task["runtime_exit_code"] = run["returncode"] + task["result_path"] = str(result_path) + if not result_path.exists(): + return fail_task( + path, + task, + "result_missing", + "coven-code exited {} without writing result.json: {}".format( + run["returncode"], run["stderr"] + ), + ) + task["state"] = "completed" if run["returncode"] in (0, 1, 3) else "failed" + task["updated_at"] = utc_now() + publish_result_if_configured(task, result_path, token) + write_json_atomic(path, task) + return task + except Exception as exc: + return fail_task(path, task, "infra_error", repr(exc)) + + +def command_exists(command): + probe = run_command(["/bin/sh", "-lc", "command -v {}".format(shell_quote(command))], timeout=10) + return probe["returncode"] == 0 + + +def shell_quote(value): + return "'" + str(value).replace("'", "'\"'\"'") + "'" + + +def pr_number_for_task(task): + task_data = task.get("task") or {} + target = task.get("target") or {} + if target.get("kind") == "pull_request" and target.get("pr_number"): + return int(target.get("pr_number")) + value = task_data.get("pr_number") + if value: + return int(value) + return None + + +def prepare_review_context(task, workspace, token, env, attempt_dir): + pr_number = pr_number_for_task(task) + if not pr_number: + return None + + repo = task.get("repository") + pr = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}".format(repo, pr_number), + token, + ) + files = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}/files?per_page=100".format(repo, pr_number), + token, + ) + + fetch = run_command( + ["git", "fetch", "--depth", "1", "origin", "pull/{}/head".format(pr_number)], + cwd=str(workspace), + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "fetch-pr.json", redacted_command_result(fetch)) + if fetch["returncode"] != 0: + return { + "kind": "pull_request", + "pr_number": pr_number, + "fetch_error": fetch["stderr"], + "metadata": summarize_pr(pr), + "files": summarize_pr_files(files), + } + + checkout = run_command(["git", "checkout", "--detach", "FETCH_HEAD"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "checkout-pr.json", redacted_command_result(checkout)) + + head = run_command(["git", "rev-parse", "HEAD"], cwd=str(workspace), env=env) + status = run_command(["git", "status", "--short", "--branch"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "workspace-git.json", redacted_command_result({ + "args": ["git evidence"], + "returncode": 0 if head["returncode"] == 0 and status["returncode"] == 0 else 1, + "stdout": "HEAD={}\n{}".format(head["stdout"].strip(), status["stdout"].strip()), + "stderr": head["stderr"] + status["stderr"], + })) + + return { + "kind": "pull_request", + "pr_number": pr_number, + "metadata": summarize_pr(pr), + "files": summarize_pr_files(files), + "checkout": { + "fetch_returncode": fetch["returncode"], + "checkout_returncode": checkout["returncode"], + "workspace_head_sha": head["stdout"].strip(), + "workspace_status": status["stdout"].strip(), + }, + } + + +def summarize_pr(pr): + return { + "number": pr.get("number"), + "title": pr.get("title"), + "state": pr.get("state"), + "html_url": pr.get("html_url"), + "base_ref": (pr.get("base") or {}).get("ref"), + "base_sha": (pr.get("base") or {}).get("sha"), + "head_ref": (pr.get("head") or {}).get("ref"), + "head_sha": (pr.get("head") or {}).get("sha"), + "merge_commit_sha": pr.get("merge_commit_sha"), + } + + +def summarize_pr_files(files): + summarized = [] + for item in files or []: + patch = item.get("patch") or "" + summarized.append( + { + "filename": item.get("filename"), + "status": item.get("status"), + "additions": item.get("additions"), + "deletions": item.get("deletions"), + "changes": item.get("changes"), + "sha": item.get("sha"), + "patch": patch[:12000], + "patch_truncated": len(patch) > 12000, + } + ) + return summarized + + +def review_evidence(review_context, review_context_path, task): + metadata = review_context.get("metadata") or {} + files = review_context.get("files") or [] + checkout = review_context.get("checkout") or {} + return { + "pr_number": review_context.get("pr_number"), + "base_ref": metadata.get("base_ref"), + "base_sha": metadata.get("base_sha"), + "head_ref": metadata.get("head_ref"), + "head_sha": metadata.get("head_sha"), + "workspace_head_sha": checkout.get("workspace_head_sha"), + "changed_file_count": len(files), + "changed_files": [f.get("filename") for f in files], + "review_context_path": str(review_context_path), + "review_context_sha256": file_sha256(review_context_path), + } + + +def file_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def publish_result_if_configured(task, result_path, token): + publication = task.get("publication") or {} + mode = publication.get("mode") or "record_only" + if mode != "comment": + task["publication_state"] = "held_for_issue_11_publication_gates" + return + + result = read_json(result_path, {}) + number = ( + (task.get("task") or {}).get("issue_number") + or (task.get("task") or {}).get("pr_number") + ) + if not number: + task["publication_state"] = "publication_skipped_no_issue_or_pr_number" + return + + body = publication_comment_body(task, result) + repo = task.get("repository") + url = "https://api.github.com/repos/{}/issues/{}/comments".format(repo, int(number)) + try: + response = github_request("POST", url, token, {"body": body}) + task["publication_state"] = "published_comment" + task["publication_url"] = response.get("html_url") + task["publication_comment_id"] = response.get("id") + except Exception as exc: + task["publication_state"] = "publication_failed" + task["publication_error"] = redact_tokenish(repr(exc)) + + +def publication_comment_body(task, result): + status = result.get("status") or "unknown" + summary = result.get("summary") or "No summary returned." + pr_body = result.get("pr_body") or "" + files_changed = result.get("files_changed") or [] + commits = result.get("commits") or [] + task_id = task.get("task_id") or "" + evidence = task.get("review_evidence") or {} + review = result.get("review") or {} + + parts = [ + "## Cody dogfood result", + "", + "**Status:** {}".format(status), + "", + summary.strip(), + ] + if pr_body.strip() and pr_body.strip() != summary.strip(): + parts.extend(["", pr_body.strip()]) + parts.extend(["", "### Evidence"]) + if evidence: + changed_files = evidence.get("changed_files") or [] + parts.extend( + [ + "- PR: #{}".format(evidence.get("pr_number")), + "- Base: `{}` @ `{}`".format(evidence.get("base_ref"), evidence.get("base_sha")), + "- Head: `{}` @ `{}`".format(evidence.get("head_ref"), evidence.get("head_sha")), + "- Checked-out workspace HEAD: `{}`".format(evidence.get("workspace_head_sha")), + "- Changed files supplied to agent: {}".format(evidence.get("changed_file_count")), + "- Review context SHA-256: `{}`".format(evidence.get("review_context_sha256")), + ] + ) + if changed_files: + parts.append("- Files: {}".format(", ".join("`{}`".format(f) for f in changed_files[:20]))) + else: + parts.append("- No PR review evidence was captured for this run.") + parts.extend(structured_review_lines(review)) + parts.extend( + [ + "", + "**Files changed:** {}".format(len(files_changed)), + "**Commits:** {}".format(len(commits)), + "", + "_Task `{}`. Publication is enabled on the hosted test adapter only._".format(task_id), + ] + ) + return "\n".join(parts) + + +def structured_review_lines(review): + if not review: + return ["", "### Structured review", "- No structured review result was emitted."] + + lines = [ + "", + "### Structured review", + "- Mode: `{}`".format(review.get("mode") or "unknown"), + "- Evidence status: `{}`".format(review.get("evidence_status") or "unknown"), + ] + + reviewed_files = review.get("reviewed_files") or [] + lines.append("- Reviewed files: {}".format(len(reviewed_files))) + if reviewed_files: + lines.append( + "- Reviewed file list: {}".format( + ", ".join("`{}`".format(path) for path in reviewed_files[:20]) + ) + ) + if len(reviewed_files) > 20: + lines.append("- Reviewed file list truncated after 20 entries.") + + supporting_files = review.get("supporting_files") or [] + lines.append("- Supporting files inspected: {}".format(len(supporting_files))) + if supporting_files: + lines.append( + "- Supporting file list: {}".format( + ", ".join("`{}`".format(path) for path in supporting_files[:20]) + ) + ) + if len(supporting_files) > 20: + lines.append("- Supporting file list truncated after 20 entries.") + + findings = review.get("findings") or [] + lines.append("- Findings: {}".format(len(findings))) + for index, finding in enumerate(findings[:10], start=1): + location = finding.get("file") or "unknown file" + if finding.get("line") is not None: + location = "{}:{}".format(location, finding.get("line")) + lines.append( + " {}. `{}` {} - {}".format( + index, + finding.get("severity") or "unknown", + location, + finding.get("title") or "Untitled finding", + ) + ) + if len(findings) > 10: + lines.append("- Findings truncated after 10 entries.") + + no_findings_reason = review.get("no_findings_reason") + if no_findings_reason: + lines.append("- No-findings reason: {}".format(no_findings_reason)) + + tests_run = review.get("tests_run") or [] + lines.append("- Tests reported by runtime: {}".format(len(tests_run))) + for test in tests_run[:10]: + summary = test.get("output_summary") + suffix = " - {}".format(summary) if summary else "" + lines.append( + " - `{}`: `{}`{}".format( + test.get("command") or "unknown command", + test.get("status") or "unknown", + suffix, + ) + ) + if len(tests_run) > 10: + lines.append("- Test list truncated after 10 entries.") + + limitations = review.get("limitations") or [] + lines.append("- Limitations: {}".format(len(limitations))) + for limitation in limitations[:10]: + lines.append(" - {}".format(limitation)) + if len(limitations) > 10: + lines.append("- Limitation list truncated after 10 entries.") + + return lines + + +def load_codex_access_token(): + for path in codex_token_candidates(): + try: + data = read_json(path, {}) + token = str(data.get("access_token") or "").strip() + if token: + return token + except Exception: + continue + return None + + +def codex_token_candidates(): + yield CODEX_TOKENS_PATH + + coven_home = CODEX_TOKENS_PATH.parent + registry = read_json(coven_home / "accounts.json", {}) + active = ( + registry.get("providers", {}) + .get("codex", {}) + .get("active") + ) + if active: + yield coven_home / "accounts" / "codex" / str(active) / "codex_tokens.json" + + accounts_root = coven_home / "accounts" / "codex" + if accounts_root.exists(): + for path in accounts_root.glob("*/codex_tokens.json"): + yield path + + +def redacted_command_result(result): + redacted = dict(result) + for key in ("stdout", "stderr"): + redacted[key] = redact_tokenish(redacted.get(key) or "") + return redacted + + +def redact_tokenish(text): + if not text: + return text + markers = ["ghs_", "ghu_", "github_pat_", "x-access-token:"] + redacted = text + for marker in markers: + while marker in redacted: + index = redacted.find(marker) + end = index + len(marker) + while end < len(redacted) and redacted[end] not in " \n\r\t'\"": + end += 1 + redacted = redacted[:index] + marker + "[redacted]" + redacted[end:] + return redacted + + +def fail_task(path, task, reason, detail): + task["state"] = "failed" + task["failure_category"] = reason + task["failure_detail"] = redact_tokenish(str(detail))[-4000:] + task["updated_at"] = utc_now() + write_json_atomic(path, task) + return task diff --git a/docs/contracts/result.example.json b/docs/contracts/result.example.json index 53fd6ac..7796d31 100644 --- a/docs/contracts/result.example.json +++ b/docs/contracts/result.example.json @@ -1,5 +1,5 @@ { - "contract_version": "1", + "contract_version": "2", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -7,6 +7,22 @@ ], "files_changed": ["src/auth/refresh.rs"], "summary": "Fixed OAuth token refresh by adding a 60-second clock skew buffer.", - "pr_body": "## Hey, I'm Cody ๐Ÿฆ„\n\nIssue #42 traced to the OAuth refresh path not accounting for clock skew. I added a 60-second buffer to the expiry check.\n\n**Changed:** `src/auth/refresh.rs` (+12 / -3)\n**Tests:** 8/8 passing (added 2 regression cases)\n", + "pr_body": "## Hey, I'm Cody\n\nIssue #42 traced to the OAuth refresh path not accounting for clock skew. I added a 60-second buffer to the expiry check.\n\n**Changed:** `src/auth/refresh.rs` (+12 / -3)\n**Tests:** 8/8 passing (added 2 regression cases)\n", + "review": { + "mode": "none", + "evidence_status": "not_applicable", + "reviewed_files": [], + "supporting_files": [], + "findings": [], + "tests_run": [ + { + "command": "cargo test -p auth refresh", + "status": "passed", + "output_summary": "8/8 passing" + } + ], + "no_findings_reason": null, + "limitations": [] + }, "exit_reason": null } diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 0a86c4c..6b7167b 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/result.schema.json", "title": "coven-code headless result envelope", - "description": "Terminal task state coven-code --headless writes to --output. Contract version 1.", + "description": "Terminal task state coven-code --headless writes to --output. Contract version 2.", "type": "object", "additionalProperties": false, - "required": ["status", "commits", "files_changed", "summary", "pr_body"], + "required": ["contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"], "properties": { "contract_version": { "type": "string", - "const": "1" + "const": "2" }, "status": { "type": "string", @@ -36,6 +36,101 @@ }, "summary": { "type": "string" }, "pr_body": { "type": "string" }, + "review": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "evidence_status", + "reviewed_files", + "supporting_files", + "findings", + "tests_run", + "no_findings_reason", + "limitations" + ], + "properties": { + "mode": { + "type": "string", + "enum": ["none", "pull_request", "review_comment"] + }, + "evidence_status": { + "type": "string", + "enum": ["not_applicable", "complete", "partial", "missing"] + }, + "reviewed_files": { + "type": "array", + "items": { "type": "string" } + }, + "supporting_files": { + "type": "array", + "items": { "type": "string" } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "file", "line", "title", "body", "recommendation"], + "properties": { + "severity": { + "type": "string", + "enum": ["info", "low", "medium", "high", "critical"] + }, + "file": { "type": "string" }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "title": { "type": "string" }, + "body": { "type": "string" }, + "recommendation": { "type": ["string", "null"] } + } + } + }, + "tests_run": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["command", "status", "output_summary"], + "properties": { + "command": { "type": "string" }, + "status": { + "type": "string", + "enum": ["passed", "failed", "not_run", "unknown"] + }, + "output_summary": { "type": ["string", "null"] } + } + } + }, + "no_findings_reason": { "type": ["string", "null"] }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + }, + "allOf": [ + { + "if": { + "properties": { "mode": { "enum": ["pull_request", "review_comment"] } }, + "required": ["mode"] + }, + "then": { + "anyOf": [ + { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, + { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } + ], + "oneOf": [ + { "properties": { "findings": { "minItems": 1 } }, "required": ["findings"] }, + { + "properties": { + "no_findings_reason": { "type": "string", "minLength": 1 } + }, + "required": ["no_findings_reason"] + } + ] + } + } + ] + }, "exit_reason": { "type": ["string", "null"], "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error", null] diff --git a/docs/contracts/session-brief.example.json b/docs/contracts/session-brief.example.json index 8ecbc9a..92719bd 100644 --- a/docs/contracts/session-brief.example.json +++ b/docs/contracts/session-brief.example.json @@ -1,5 +1,5 @@ { - "contract_version": "1", + "contract_version": "2", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", diff --git a/docs/contracts/session-brief.schema.json b/docs/contracts/session-brief.schema.json index 613a352..850c31e 100644 --- a/docs/contracts/session-brief.schema.json +++ b/docs/contracts/session-brief.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/session-brief.schema.json", "title": "coven-code headless session brief", - "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 1.", + "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 2.", "type": "object", "additionalProperties": false, "required": ["trigger", "repo", "task", "familiar", "workspace"], "properties": { "contract_version": { "type": "string", - "const": "1" + "const": "2" }, "trigger": { "type": "string", diff --git a/docs/headless-contract.md b/docs/headless-contract.md index f71eb54..23f3fc6 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -1,6 +1,6 @@ # coven-code Headless Execution Contract -**Contract version: `1`** ยท Status: **Locked** (V1 / M1) +**Contract version: `2`** - Status: **Locked** (V2 / structured review output) This document is the single source of truth for the interface between `coven-github` (the GitHub ingress adapter) and `coven-code` (the execution @@ -64,7 +64,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is ```json { - "contract_version": "1", + "contract_version": "2", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", @@ -94,7 +94,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. Consumers MUST reject a brief whose major version they do not implement. | +| `contract_version` | string | MUST be `"2"`. Consumers MUST reject a brief whose major version they do not implement. | | `trigger` | string enum | `issue_assigned` \| `pr_review_comment` \| `issue_mention`. | | `repo.owner` | string | | | `repo.name` | string | | @@ -128,7 +128,7 @@ the file MAY be absent. ```json { - "contract_version": "1", + "contract_version": "2", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -136,7 +136,17 @@ the file MAY be absent. ], "files_changed": ["src/auth/refresh.rs"], "summary": "Fixed OAuth token refresh by adding a 60-second clock skew buffer.", - "pr_body": "## Hey, I'm Cody ๐Ÿฆ„\n\nI looked at issue #42โ€ฆ", + "pr_body": "## Hey, I'm Cody\n\nI looked at issue #42...", + "review": { + "mode": "pull_request", + "evidence_status": "complete", + "reviewed_files": ["src/auth/refresh.rs"], + "supporting_files": ["src/auth/mod.rs", "tests/auth_refresh.rs"], + "findings": [], + "tests_run": [], + "no_findings_reason": "Reviewed the supplied PR file and found no blocking issues.", + "limitations": [] + }, "exit_reason": null } ``` @@ -145,22 +155,44 @@ the file MAY be absent. | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. If absent, the consumer assumes `"1"` for backward compatibility, but producers MUST emit it. | -| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.2](#32-status). | +| `contract_version` | string | MUST be `"2"`. Producers MUST emit it. | +| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.3](#33-status). | | `branch` | string \| null | Branch the runtime pushed. `null` when no branch was created. The adapter only opens a PR when `branch` is set **and** `commits` is non-empty. | | `commits` | array | `{ "sha": string, "message": string }`. MAY be empty. | | `files_changed` | string[] | Workspace-relative paths. MAY be empty. | | `summary` | string | One-line familiar-voice summary. Used in the Check Run and PR title. | | `pr_body` | string | Full PR body, **authored by the familiar** in its own voice โ€” not a template. | -| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.3](#33-exit_reason). | +| `review` | object | Structured review evidence and findings. Required even when `mode` is `none`. See [3.2](#32-review). | +| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.4](#34-exit_reason). | > **Drift note (supersedes `COVEN-GITHUB.md`):** the prose result envelope listed -> an `events` array. Progress/event streaming is **not** part of the v1 result +> an `events` array. Progress/event streaming is **not** part of the v2 result > envelope โ€” it is deferred to M2 and will travel over a separate channel. The -> v1 envelope carries terminal task state only. Producers MUST NOT rely on +> v2 envelope carries terminal task state only. Producers MUST NOT rely on > `events` being read. -### 3.2 `status` +### 3.2 `review` + +`review` is the machine-readable proof that a hosted review actually examined +the intended code. It is required on every result. Non-review tasks MUST set +`mode: "none"` and `evidence_status: "not_applicable"`. + +| Field | Type | Notes | +|---|---|---| +| `mode` | string enum | `none`, `pull_request`, or `review_comment`. | +| `evidence_status` | string enum | `not_applicable`, `complete`, `partial`, or `missing`. PR review modes MUST NOT use `not_applicable`. | +| `reviewed_files` | string[] | Workspace-relative files supplied to or inspected by the runtime. PR review modes MUST include at least one file unless `evidence_status` is `missing`. | +| `supporting_files` | string[] | Workspace-relative files beyond the changed-file list that the runtime can prove were inspected for context. Empty means no broader-codebase inspection was proven, not that none was needed. | +| `findings` | array | Structured findings. Empty is allowed only when `no_findings_reason` is a non-empty string. | +| `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. | +| `no_findings_reason` | string \| null | Required when `mode` is a review mode and `findings` is empty. | +| `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. | + +Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and +optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`, +and `critical`. + +### 3.3 `status` | Value | Meaning | |---|---| @@ -172,7 +204,7 @@ the file MAY be absent. The adapter treats `success` and `partial` as PR-opening outcomes; `failure` and `needs_input` do not open a PR by themselves. -### 3.3 `exit_reason` +### 3.4 `exit_reason` `null` on success. Otherwise one of: @@ -211,7 +243,7 @@ A process **killed by signal**, or one that **times out** (the adapter enforces ## 5. Security invariants -These are non-negotiable for v1: +These are non-negotiable for v2: 1. The session brief is **tokenless**. Serializing a brief MUST NOT produce an `auth` field, a `"token"` field, or a credential-bearing `clone_url`