diff --git a/.github/scripts/agent_delegation_policy.js b/.github/scripts/agent_delegation_policy.js index 3571ad92f..657d8ca1b 100644 --- a/.github/scripts/agent_delegation_policy.js +++ b/.github/scripts/agent_delegation_policy.js @@ -84,7 +84,7 @@ function decideNextAgent({ state = {}, labels = [], secrets = {}, registry = {}, // Current agent exists - check if we should continue or switch const effectiveness = calculateEffectiveness({ history, lookbackRounds: 3, core }); - const stall = detectStall({ history, threshold: 3, core }); + const stall = detectStall({ history, threshold: 2, core }); const roundsSinceSwitch = currentIteration - lastSwitchIteration; const inCooldown = roundsSinceSwitch < 5; @@ -224,15 +224,13 @@ function calculateEffectiveness({ history = [], lookbackRounds = 3, core }) { const tasks = recentRounds.reduce((sum, round) => sum + (round.tasks || 0), 0); const gatePassed = recentRounds.some((round) => round.gate === 'pass'); - // Agent is effective only when it produced real forward motion: - // - Made at least 1 commit in lookback window, OR - // - Completed at least 1 task in lookback window. - // A green Gate with zero commits and zero tasks is NOT progress: a normal - // `run` dispatch only happens on a green Gate (Activation Guardrail §2), so a - // genuinely stuck agent records `gate: 'pass'` every round. Counting that as - // effective made the stall detector unable to ever fire (#2268). `gatePassed` - // is still returned/reported below, just not treated as progress. - const effective = commits >= 1 || tasks >= 1; + // Agent is effective only when it produced verified forward motion: + // - Completed at least 1 task in the lookback window, OR + // - Made commits and has a green Gate signal in the lookback window. + // Bare commits with no checkbox progress and a non-green Gate are churn, not + // progress; otherwise an agent can commit indefinitely without advancing + // acceptance criteria or CI and never trip delegation. + const effective = tasks >= 1 || (commits >= 1 && gatePassed); const summary = [ commits > 0 ? `${commits} commits` : null, @@ -262,7 +260,7 @@ function calculateEffectiveness({ history = [], lookbackRounds = 3, core }) { * @param {Object} [options.core] - GitHub Actions core for logging * @returns {Object} - { isStalled, consecutiveRounds, reason } */ -function detectStall({ history = [], threshold = 3, core }) { +function detectStall({ history = [], threshold = 2, core }) { if (history.length < threshold) { return { isStalled: false, @@ -280,8 +278,8 @@ function detectStall({ history = [], threshold = 3, core }) { // keeps a green Gate while making zero commits never trips the stall // threshold and `agent:auto` delegation can never switch (#2268). const hasProgress = - (round.commits || 0) > 0 || - (round.tasks || 0) > 0; + (round.tasks || 0) > 0 || + ((round.commits || 0) > 0 && round.gate === 'pass'); if (hasProgress) { break; // Found progress, stop counting diff --git a/.github/scripts/keepalive_loop.js b/.github/scripts/keepalive_loop.js index cd7d119b7..522a16e0f 100644 --- a/.github/scripts/keepalive_loop.js +++ b/.github/scripts/keepalive_loop.js @@ -142,6 +142,24 @@ function toNumber(value, fallback = 0) { return Number.isFinite(fallback) ? Number(fallback) : 0; } +function toPositiveInteger(value, fallback = 0) { + const fallbackValue = Number.isSafeInteger(fallback) && fallback > 0 ? fallback : 0; + + if (typeof value === 'number') { + return Number.isSafeInteger(value) && value > 0 ? value : fallbackValue; + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (/^[1-9]\d*$/.test(trimmed)) { + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : fallbackValue; + } + } + + return fallbackValue; +} + function toOptionalNumber(value) { if (value === null || value === undefined || value === '') { return null; @@ -1593,7 +1611,7 @@ function normaliseConfig(config = {}) { ), autofix_enabled: toBool(cfg.autofix_enabled ?? cfg.autofix, false), iteration: toNumber(cfg.iteration ?? cfg.keepalive_iteration, 0), - max_iterations: toNumber(cfg.max_iterations ?? cfg.keepalive_max_iterations, 5), + max_iterations: cfg.max_iterations ?? cfg.keepalive_max_iterations, failure_threshold: toNumber(cfg.failure_threshold ?? cfg.keepalive_failure_threshold, 3), trace, prompt_mode: promptMode, @@ -2503,7 +2521,9 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload // Prefer state iteration unless config explicitly sets it (0 from config is default, not explicit) const configHasExplicitIteration = config.iteration > 0; const iteration = configHasExplicitIteration ? config.iteration : toNumber(state.iteration, 0); - const maxIterations = toNumber(config.max_iterations ?? state.max_iterations, 5); + const configMaxIterations = toPositiveInteger(config.max_iterations, 0); + const stateMaxIterations = toPositiveInteger(state.max_iterations, 0); + const maxIterations = configMaxIterations || stateMaxIterations || 12; const failureThreshold = toNumber(config.failure_threshold ?? state.failure_threshold, 3); const progressReviewThreshold = toNumber(config.progress_review_threshold ?? state.progress_review_threshold, 4); // Default 3 rounds allows 2 fix attempts before stopping (round 1 = fix, @@ -2595,14 +2615,11 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload // An iteration is productive if it has a reasonable productivity score const isProductive = productivityScore >= 20 && !hasRecentFailures; - // max_iterations is a soft cap on agent runs. Productive agents - // (recent file changes, no persistent failures) with remaining tasks - // continue in "extended mode" (reason: ready-extended) past the cap. - // Unproductive agents are hard-stopped to avoid wasting compute. - // Use the agent:retry label to force-continue regardless. + // max_iterations is a hard per-PR budget. Once reached, stop dispatching + // and require a human to raise the budget or remove the blocker. const hasMaxIterations = maxIterations > 0; const reachedMaxIterations = hasMaxIterations && iteration >= maxIterations; - const shouldStopForMaxIterations = reachedMaxIterations && !isProductive; + const shouldStopForMaxIterations = reachedMaxIterations; // Build task appendix for the agent prompt (after state load for reconciliation info) const taskAppendix = buildTaskAppendix(normalisedSections, checkboxCounts, state, { prBody: pr.body }); @@ -2663,6 +2680,9 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload } else if (!tasksPresent) { action = 'stop'; reason = 'no-checklists'; + } else if (shouldStopForMaxIterations) { + action = 'stop'; + reason = 'round-budget-exhausted'; } else if (gateNormalized !== 'success') { // Handle cancelled gate first (transient — should not consume fix budget) if (gateNormalized === 'cancelled') { @@ -2768,13 +2788,6 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload `Agent produced 0 file changes and 0 tasks completed for ${persistedConsecutiveZeroActivityRounds} consecutive rounds — likely infrastructure failure (auth, permissions, sandbox). Stopping.`, ); } - } else if (shouldStopForMaxIterations && forceRetry && tasksRemaining) { - action = 'run'; - reason = 'force-retry-max-iterations'; - if (core) core.info('Force retry enabled: bypassing max-iterations stop'); - } else if (shouldStopForMaxIterations) { - action = 'stop'; - reason = isProductive ? 'max-iterations' : 'max-iterations-unproductive'; } else if (needsProgressReview) { // Trigger LLM-based progress review when agent is active but not completing tasks // This allows legitimate prep work while catching scope drift early @@ -2783,7 +2796,7 @@ async function evaluateKeepaliveLoop({ github: rawGithub, context, core, payload reason = `progress-review-${roundsWithoutTaskCompletion}`; } else if (tasksRemaining) { action = 'run'; - reason = iteration >= maxIterations ? 'ready-extended' : 'ready'; + reason = 'ready'; } // Scope enforcement: if all tasks appear complete but there are scope diff --git a/.github/scripts/verifier_verdict_json.py b/.github/scripts/verifier_verdict_json.py new file mode 100644 index 000000000..1df7d90de --- /dev/null +++ b/.github/scripts/verifier_verdict_json.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Extract an unspoofable verifier verdict from structured agent output.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +VERDICT_RE = re.compile( + r"\b[\"']?verdict[\"']?\s*:\s*[\"']?(pass|fail)[\"']?\b", + re.IGNORECASE, +) +FENCED_BLOCK_RE = re.compile( + r"^[ \t]*```(?P[^\n`]*)\n(?P.*?)^[ \t]*```[ \t]*$", + re.MULTILINE | re.DOTALL, +) + +VALID_VERDICTS = {"pass", "concerns", "fail", "error"} + + +def _normalize_verdict(value: object) -> str: + verdict = str(value or "").strip().lower().replace("_", "-") + if verdict in {"passed", "success"}: + return "pass" + if verdict in {"needs-review", "needs review", "review", "concern", "concerns"}: + return "concerns" + if verdict in {"failed", "failure"}: + return "fail" + return verdict if verdict in VALID_VERDICTS else "" + + +def _diff_regions(markdown: str) -> list[str]: + regions: list[str] = [] + for match in FENCED_BLOCK_RE.finditer(markdown): + lang = match.group("lang").strip().lower() + if lang in {"diff", "patch"}: + regions.append(match.group("body")) + return regions + + +def _without_diff_regions(markdown: str) -> str: + def replace(match: re.Match[str]) -> str: + lang = match.group("lang").strip().lower() + return "\n" if lang in {"diff", "patch"} else match.group(0) + + return FENCED_BLOCK_RE.sub(replace, markdown) + + +def _json_candidates(markdown: str) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + for match in FENCED_BLOCK_RE.finditer(markdown): + if match.group("lang").strip().lower() != "json": + continue + try: + parsed = json.loads(match.group("body")) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + candidates.append(parsed) + + return candidates + + +def build_verdict(output: str) -> dict[str, Any]: + for region in _diff_regions(output): + if VERDICT_RE.search(region): + return { + "verdict": "fail", + "source": "diff-tamper", + "needs_attention": True, + "reason": "verdict marker appeared inside a diff/patch region", + } + + output_without_diff = _without_diff_regions(output) + for candidate in _json_candidates(output_without_diff): + verdict = _normalize_verdict(candidate.get("verdict")) + if not verdict: + continue + return { + **candidate, + "verdict": verdict, + "source": "structured-json", + "needs_attention": bool(candidate.get("needs_attention", verdict != "pass")), + } + + return { + "verdict": "error", + "source": "missing-structured-json", + "needs_attention": True, + "reason": "no structured verifier JSON verdict found outside diff regions", + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", required=True, help="Verifier agent output markdown") + parser.add_argument("--json", required=True, help="Destination verdict JSON path") + args = parser.parse_args() + + output = Path(args.output).read_text(encoding="utf-8") if Path(args.output).is_file() else "" + verdict = build_verdict(output) + destination = Path(args.json) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(json.dumps(verdict, sort_keys=True) + "\n", encoding="utf-8") + print(f"verdict={verdict['verdict']}") + print(f"source={verdict['source']}") + if verdict.get("reason"): + print(f"reason={verdict['reason']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/agents-71-codex-belt-dispatcher.yml b/.github/workflows/agents-71-codex-belt-dispatcher.yml index 5a77d0e95..53b937464 100644 --- a/.github/workflows/agents-71-codex-belt-dispatcher.yml +++ b/.github/workflows/agents-71-codex-belt-dispatcher.yml @@ -20,6 +20,19 @@ on: required: false default: false type: boolean + orchestrator_skill_pack: + description: >- + Optional reference-pack name override for exported Orchestrator skill context on + downstream Codex opener/closer runs (repo config remains primary). + required: false + default: '' + type: string + orchestrator_skill_enabled: + description: >- + Optional enabled override for exported Orchestrator skill context (true/false). + required: false + default: '' + type: string secrets: ACTIONS_BOT_PAT: required: false @@ -63,6 +76,19 @@ on: required: false default: false type: boolean + orchestrator_skill_pack: + description: >- + Optional reference-pack name override for exported Orchestrator skill context on + downstream Codex opener/closer runs (repo config remains primary). + required: false + default: '' + type: string + orchestrator_skill_enabled: + description: >- + Optional enabled override for exported Orchestrator skill context (true/false). + required: false + default: '' + type: string permissions: contents: write diff --git a/.github/workflows/agents-72-codex-belt-worker-dispatch.yml b/.github/workflows/agents-72-codex-belt-worker-dispatch.yml index 543af84f5..793067adb 100644 --- a/.github/workflows/agents-72-codex-belt-worker-dispatch.yml +++ b/.github/workflows/agents-72-codex-belt-worker-dispatch.yml @@ -37,16 +37,24 @@ on: required: false default: false type: boolean - max_parallel: - description: 'Maximum concurrent worker runs permitted' - required: false - default: '1' - type: string keepalive: description: 'True when invocation originates from a keepalive sweep' required: false default: false type: boolean + orchestrator_skill_pack: + description: >- + Optional reference-pack name override for exported Orchestrator skill context on + downstream Codex opener/closer runs (repo config remains primary). + required: false + default: '' + type: string + orchestrator_skill_enabled: + description: >- + Optional enabled override for exported Orchestrator skill context (true/false). + required: false + default: '' + type: string permissions: contents: write @@ -66,8 +74,10 @@ jobs: source: ${{ inputs.source }} dry_run: ${{ inputs.dry_run }} use_step_branch: ${{ inputs.use_step_branch }} - max_parallel: ${{ fromJSON(inputs.max_parallel) }} + max_parallel: 1 keepalive: ${{ inputs.keepalive }} + orchestrator_skill_pack: ${{ inputs.orchestrator_skill_pack }} + orchestrator_skill_enabled: ${{ inputs.orchestrator_skill_enabled }} secrets: WORKFLOWS_APP_ID: ${{ secrets.WORKFLOWS_APP_ID }} WORKFLOWS_APP_PRIVATE_KEY: ${{ secrets.WORKFLOWS_APP_PRIVATE_KEY }} diff --git a/.github/workflows/agents-72-codex-belt-worker.yml b/.github/workflows/agents-72-codex-belt-worker.yml index c5bc007f8..bec3427a0 100644 --- a/.github/workflows/agents-72-codex-belt-worker.yml +++ b/.github/workflows/agents-72-codex-belt-worker.yml @@ -48,6 +48,19 @@ on: required: false default: false type: boolean + orchestrator_skill_pack: + description: >- + Optional reference-pack name override for exported Orchestrator skill context on + downstream Codex opener/closer runs (repo config remains primary). + required: false + default: '' + type: string + orchestrator_skill_enabled: + description: >- + Optional enabled override for exported Orchestrator skill context (true/false). + required: false + default: '' + type: string secrets: WORKFLOWS_APP_ID: required: false diff --git a/scripts/check_agents_md_freshness.py b/scripts/check_agents_md_freshness.py new file mode 100644 index 000000000..9ef30d028 --- /dev/null +++ b/scripts/check_agents_md_freshness.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Warn when the managed Orchestrator AGENTS.md section cites stale repo facts.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +MANAGED_START = "" +MANAGED_END = "" +PATH_SUFFIXES = { + ".cfg", + ".ini", + ".js", + ".json", + ".md", + ".py", + ".sh", + ".toml", + ".txt", + ".yaml", + ".yml", +} + + +@dataclass(frozen=True) +class Finding: + kind: str + value: str + message: str + + def as_dict(self) -> dict[str, str]: + return {"kind": self.kind, "value": self.value, "message": self.message} + + +def managed_section(text: str) -> str | None: + start = text.find(MANAGED_START) + end = text.find(MANAGED_END, start + len(MANAGED_START)) if start >= 0 else -1 + if start < 0 or end < 0 or end < start: + return None + return text[start : end + len(MANAGED_END)] + + +def _clean_ref(value: str) -> str: + value = value.strip().strip("\"'") + value = re.sub(r"[:#]L?\d+(?:-L?\d+)?$", "", value) + return value + + +def _looks_like_path(value: str) -> bool: + if value.startswith(("./", "../", ".github/", "docs/", "scripts/", "templates/", "tools/")): + return True + path = Path(value) + return "/" in value or path.suffix.lower() in PATH_SUFFIXES + + +def _path_exists(repo_root: Path, ref: str) -> bool: + return (repo_root / ref).exists() + + +def _command_exists(repo_root: Path, ref: str) -> bool: + parts = ref.split() + if not parts: + return True + command = parts[0] + if command.startswith(("./", "../")) or "/" in command: + return (repo_root / command).exists() + return shutil.which(command) is not None + + +def _check_command_ref(repo_root: Path, ref: str) -> list[Finding]: + findings: list[Finding] = [] + if not _command_exists(repo_root, ref): + findings.append(Finding("command", ref, f"referenced command not found: {ref}")) + for arg in ref.split()[1:]: + arg = _clean_ref(arg) + if "=" in arg: + _, arg = arg.split("=", 1) + arg = _clean_ref(arg) + if _looks_like_path(arg) and not _path_exists(repo_root, arg): + findings.append(Finding("path", arg, f"referenced path not found: {arg}")) + return findings + + +def cited_refs(section: str) -> list[str]: + refs: list[str] = [] + for raw in re.findall(r"`([^`]+)`", section): + value = _clean_ref(raw) + if not value or value.startswith(("http://", "https://")): + continue + refs.append(value) + return refs + + +def check_agents_md(repo_root: Path, agents_md: Path | None = None) -> list[Finding]: + agents_path = agents_md or repo_root / "AGENTS.md" + if not agents_path.exists(): + return [] + section = managed_section(agents_path.read_text(encoding="utf-8")) + if section is None: + return [] + + findings: list[Finding] = [] + seen: set[tuple[str, str]] = set() + for ref in cited_refs(section): + if " " in ref: + for finding in _check_command_ref(repo_root, ref): + key = (finding.kind, finding.value) + if key not in seen: + findings.append(finding) + seen.add(key) + elif _looks_like_path(ref): + key = ("path", ref) + if key not in seen and not _path_exists(repo_root, ref): + findings.append(Finding("path", ref, f"referenced path not found: {ref}")) + seen.add(key) + return findings + + +def _emit_github_warnings(findings: list[Finding]) -> None: + for finding in findings: + message = finding.message.replace("%", "%25").replace("\n", "%0A").replace("\r", "%0D") + print(f"::warning title=AGENTS.md freshness::{message}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--agents-md", type=Path) + parser.add_argument("--github-annotations", action="store_true") + parser.add_argument("--json", action="store_true", dest="as_json") + parser.add_argument( + "--strict", action="store_true", help="Exit non-zero when findings are present." + ) + args = parser.parse_args(argv) + + repo_root = args.repo_root.resolve() + agents_md = args.agents_md.resolve() if args.agents_md else None + findings = check_agents_md(repo_root, agents_md) + + if args.as_json: + print(json.dumps({"findings": [finding.as_dict() for finding in findings]}, indent=2)) + elif findings: + for finding in findings: + print(finding.message) + else: + print("AGENTS.md managed section freshness check passed.") + + if args.github_annotations and findings: + _emit_github_warnings(findings) + + return 1 if args.strict and findings else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/orchestrator_skill.py b/scripts/orchestrator_skill.py new file mode 100644 index 000000000..cebe93da8 --- /dev/null +++ b/scripts/orchestrator_skill.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +"""Load, validate, and materialize exported Orchestrator skill context for remote Codex runs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +DEFAULT_CONFIG_RELPATH = ".github/orchestrator_skill.json" +SUMMARY_RELPATH = ".reference/ORCHESTRATOR_SKILL.md" +DEFAULT_CHECKOUT_PATH = ".reference/orchestrator-skill" +FORBIDDEN_PATH_MARKERS = ( + "/users/", + "~/.codex", + "/.codex/", + "library/cloudstorage/dropbox/learning/code/orchestrator", + "orchestrator/feedback", + "orchestrator/brain", +) +FORBIDDEN_VALUE_MARKERS = ( + "/users/teacher/.codex", + "library/cloudstorage/dropbox/learning/code/orchestrator", +) + + +class OrchestratorSkillConfigError(ValueError): + """Raised when orchestrator skill configuration is invalid.""" + + +@dataclass(frozen=True) +class OrchestratorSkillCheckoutPlan: + """Workflow-ready checkout plan for exported Orchestrator skill material.""" + + repo: str + ref: str + paths: list[str] + checkout_path: str + pack: str | None = None + + +@dataclass(frozen=True) +class OrchestratorSkillSnapshot: + exists: bool + enabled: bool + config_path: Path + config_text: str | None + plan: OrchestratorSkillCheckoutPlan | None + + +def orchestrator_skill_config_path(workspace: Path | str = ".") -> Path: + return Path(workspace).resolve() / DEFAULT_CONFIG_RELPATH + + +def orchestrator_skill_config_exists(workspace: Path | str = ".") -> bool: + return orchestrator_skill_config_path(workspace).is_file() + + +def read_orchestrator_skill_config_text( + workspace: Path | str = ".", +) -> tuple[Path, str | None]: + config_path = orchestrator_skill_config_path(workspace) + if not config_path.is_file(): + return config_path, None + try: + return config_path, config_path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise OrchestratorSkillConfigError( + f"Malformed text in {config_path}: file must be valid UTF-8" + ) from exc + + +def _require_nonempty_string(value: Any, field_name: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise OrchestratorSkillConfigError(f"{field_name} must be a non-empty string") + return value.strip() + + +def _validate_repo(repo: str) -> str: + if "/" not in repo or repo.startswith("/") or repo.endswith("/"): + raise OrchestratorSkillConfigError("repo must use owner/name format") + return repo + + +def _validate_paths(raw_paths: Any) -> list[str]: + if not isinstance(raw_paths, list) or not raw_paths: + raise OrchestratorSkillConfigError("paths must be a non-empty array of strings") + + validated: list[str] = [] + for entry in raw_paths: + path = _require_nonempty_string(entry, "paths[]") + lowered = path.lower() + if any(marker in lowered for marker in FORBIDDEN_PATH_MARKERS): + raise OrchestratorSkillConfigError( + f"paths[] must not reference local Orchestrator runtime paths: {path}" + ) + if path.startswith("/"): + raise OrchestratorSkillConfigError("paths[] must be relative, not absolute") + if ".." in path.split("/"): + raise OrchestratorSkillConfigError("paths[] must not traverse parent directories") + validated.append(path) + return validated + + +def _reject_local_runtime_values(value: str, field_name: str) -> None: + lowered = value.lower() + if any(marker in lowered for marker in FORBIDDEN_VALUE_MARKERS): + raise OrchestratorSkillConfigError( + f"{field_name} must not reference local Orchestrator runtime paths" + ) + + +def _coerce_enabled(value: Any, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise OrchestratorSkillConfigError("enabled must be a boolean") + + +def parse_orchestrator_skill_config( + payload: Any, +) -> tuple[bool, OrchestratorSkillCheckoutPlan | None]: + if not isinstance(payload, dict): + raise OrchestratorSkillConfigError("orchestrator_skill.json must contain a JSON object") + + enabled = _coerce_enabled(payload.get("enabled"), default=True) + if not enabled: + return False, None + + pack = payload.get("pack") + repo = payload.get("repo") + ref = payload.get("ref") + paths = payload.get("paths") + + has_pack = pack is not None + has_inline = any(item is not None for item in (repo, ref, paths)) + if has_pack and has_inline: + raise OrchestratorSkillConfigError("use either 'pack' or inline repo/ref/paths, not both") + if not has_pack and not has_inline: + raise OrchestratorSkillConfigError( + "enabled orchestrator skill config requires 'pack' or inline repo/ref/paths" + ) + + if has_pack: + pack_name = _require_nonempty_string(pack, "pack") + _reject_local_runtime_values(pack_name, "pack") + return True, OrchestratorSkillCheckoutPlan( + repo="", + ref="", + paths=[], + checkout_path=DEFAULT_CHECKOUT_PATH, + pack=pack_name, + ) + + validated_repo = _validate_repo(_require_nonempty_string(repo, "repo")) + validated_ref = _require_nonempty_string(ref, "ref") + validated_paths = _validate_paths(paths) + _reject_local_runtime_values(validated_repo, "repo") + _reject_local_runtime_values(validated_ref, "ref") + + return True, OrchestratorSkillCheckoutPlan( + repo=validated_repo, + ref=validated_ref, + paths=validated_paths, + checkout_path=DEFAULT_CHECKOUT_PATH, + pack=None, + ) + + +def parse_orchestrator_skill_config_text( + config_text: str, + config_path: Path, +) -> tuple[bool, OrchestratorSkillCheckoutPlan | None]: + try: + payload = json.loads(config_text) + except json.JSONDecodeError as exc: + raise OrchestratorSkillConfigError( + f"Malformed JSON in {config_path}: line {exc.lineno} column {exc.colno}: {exc.msg}" + ) from exc + try: + return parse_orchestrator_skill_config(payload) + except OrchestratorSkillConfigError as exc: + raise OrchestratorSkillConfigError(f"Invalid config in {config_path}: {exc}") from exc + + +def resolve_orchestrator_skill_plan( + workspace: Path | str = ".", + *, + pack_override: str | None = None, + enabled_override: bool | None = None, +) -> OrchestratorSkillCheckoutPlan | None: + config_path, config_text = read_orchestrator_skill_config_text(workspace) + enabled = False + plan: OrchestratorSkillCheckoutPlan | None = None + + if config_text is not None: + enabled, plan = parse_orchestrator_skill_config_text(config_text, config_path) + elif pack_override: + enabled = enabled_override is not False + plan = OrchestratorSkillCheckoutPlan( + repo="", + ref="", + paths=[], + checkout_path=DEFAULT_CHECKOUT_PATH, + pack=pack_override, + ) + + if enabled_override is not None: + enabled = enabled_override + + if not enabled: + return None + + if pack_override: + if plan is None: + plan = OrchestratorSkillCheckoutPlan( + repo="", + ref="", + paths=[], + checkout_path=DEFAULT_CHECKOUT_PATH, + pack=pack_override, + ) + else: + plan = OrchestratorSkillCheckoutPlan( + repo=plan.repo, + ref=plan.ref, + paths=list(plan.paths), + checkout_path=plan.checkout_path, + pack=pack_override, + ) + + return plan + + +def load_orchestrator_skill( + workspace: Path | str = ".", + *, + pack_override: str | None = None, + enabled_override: bool | None = None, +) -> OrchestratorSkillSnapshot: + config_path, config_text = read_orchestrator_skill_config_text(workspace) + plan = resolve_orchestrator_skill_plan( + workspace, + pack_override=pack_override, + enabled_override=enabled_override, + ) + enabled = plan is not None + if config_text is None and enabled_override is False: + enabled = False + return OrchestratorSkillSnapshot( + exists=config_text is not None, + enabled=enabled, + config_path=config_path, + config_text=config_text, + plan=plan, + ) + + +def build_orchestrator_skill_summary( + checkout_path: Path, + *, + pack_name: str | None = None, +) -> str: + files = sorted( + path.relative_to(checkout_path).as_posix() + for path in checkout_path.rglob("*") + if path.is_file() + ) + primary = files[0] if files else "(no files materialized)" + pack_line = f"- **Reference pack:** `{pack_name}`\n" if pack_name else "" + file_lines = "\n".join(f"- `{rel}`" for rel in files) or "- `(empty)`" + return ( + "\n".join( + [ + "This section provides **exported Orchestrator instructions** for remote Codex runs.", + "It is **not** a live mount of the local Orchestrator Brain, feedback database, worktrees, or credentials.", + "", + "**Read and apply the materialized Orchestrator skill files before coordinating work.**", + "Use the exported policy for decomposition and judgment only; do not attempt to access local Orchestrator runtime tools or state.", + "", + f"- **Location:** `{checkout_path.as_posix()}/`", + pack_line.rstrip(), + f"- **Primary entry point:** `{primary}`", + "", + "### Materialized files", + file_lines, + ] + ).strip() + + "\n" + ) + + +def write_orchestrator_skill_summary( + workspace: Path | str, + checkout_path: Path | str, + *, + pack_name: str | None = None, +) -> Path: + workspace_path = Path(workspace).resolve() + summary_path = workspace_path / SUMMARY_RELPATH + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text( + build_orchestrator_skill_summary(Path(checkout_path), pack_name=pack_name), + encoding="utf-8", + ) + return summary_path + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Read and validate .github/orchestrator_skill.json" + ) + parser.add_argument("--workspace", default=".", help="Workspace root") + parser.add_argument( + "--format", + choices=["json", "self-check"], + default="json", + help="Output format", + ) + parser.add_argument( + "--pack-override", + default="", + help="Optional reference-pack name override", + ) + parser.add_argument( + "--enabled-override", + default="", + help="Optional enabled override (true/false); empty means use repo config", + ) + return parser + + +def _parse_enabled_override(raw: str) -> bool | None: + normalized = raw.strip().lower() + if not normalized: + return None + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise OrchestratorSkillConfigError("enabled override must be true or false") + + +def main(argv: list[str] | None = None) -> int: + args = _build_parser().parse_args(argv) + pack_override = args.pack_override.strip() or None + enabled_override = _parse_enabled_override(args.enabled_override) + + try: + snapshot = load_orchestrator_skill( + args.workspace, + pack_override=pack_override, + enabled_override=enabled_override, + ) + except OrchestratorSkillConfigError as exc: + print(f"Orchestrator skill config error: {exc}", file=sys.stderr) + return 2 + + if args.format == "self-check": + if not snapshot.exists: + print( + "Orchestrator skill self-check: skipped " f"({snapshot.config_path} not found).", + file=sys.stderr, + ) + return 0 + if not snapshot.enabled: + print( + "Orchestrator skill self-check: disabled " f"({snapshot.config_path}).", + file=sys.stderr, + ) + return 0 + print( + "Orchestrator skill self-check: OK " f"(enabled from {snapshot.config_path}).", + file=sys.stderr, + ) + return 0 + + payload = { + "exists": snapshot.exists, + "enabled": snapshot.enabled, + "config_path": str(snapshot.config_path), + "config_text": snapshot.config_text, + "plan": asdict(snapshot.plan) if snapshot.plan else None, + } + print(json.dumps(payload, separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/runner_lib/core.py b/scripts/runner_lib/core.py index 207498d36..c4933094a 100644 --- a/scripts/runner_lib/core.py +++ b/scripts/runner_lib/core.py @@ -118,6 +118,18 @@ def _load_reference_packs_module() -> Any: raise +def _load_orchestrator_skill_module() -> Any: + try: + return importlib.import_module("scripts.orchestrator_skill") + except ModuleNotFoundError as exc: + if exc.name == "scripts.orchestrator_skill": + raise RuntimeError( + "orchestrator skill context is not supported in this repository because " + "scripts/orchestrator_skill.py was not synced" + ) from exc + raise + + def _run_git(args: list[str], env: dict[str, str] | None = None) -> None: try: subprocess.check_call( @@ -241,6 +253,141 @@ def materialize_reference_packs( return summary_path +def _materialize_single_checkout_plan( + workspace_path: Path, + *, + repo: str, + ref: str, + paths: list[str], + checkout_path: str, + token: str | None, +) -> Path: + clone_parent = Path(tempfile.mkdtemp(prefix="orchestrator-skill-")) + clone_dir = clone_parent / "repo" + askpass_path = clone_parent / "git-askpass.sh" + git_env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + if token: + askpass_path.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + " *Username*) printf '%s\\n' \"${GIT_ASKPASS_USERNAME:-x-access-token}\" ;;\n" + " *) printf '%s\\n' \"$GIT_ASKPASS_PASSWORD\" ;;\n" + "esac\n", + encoding="utf-8", + ) + askpass_path.chmod(0o700) + git_env.update( + { + "GIT_ASKPASS": str(askpass_path), + "GIT_ASKPASS_USERNAME": "x-access-token", + "GIT_ASKPASS_PASSWORD": token, + } + ) + + clone_url = f"https://github.com/{repo}.git" + clone_cmd = ["git", "clone", "--depth=1", "--filter=blob:none", "--sparse"] + is_sha = bool(re.fullmatch(r"[0-9a-fA-F]{40}", ref)) + if not is_sha: + clone_cmd.extend(["--branch", ref]) + clone_cmd.extend([clone_url, str(clone_dir)]) + try: + _run_git(clone_cmd, env=git_env) + + if is_sha: + _run_git( + ["git", "-C", str(clone_dir), "fetch", "origin", ref, "--depth=1"], + env=git_env, + ) + _run_git(["git", "-C", str(clone_dir), "checkout", ref], env=git_env) + + _run_git( + [ + "git", + "-C", + str(clone_dir), + "sparse-checkout", + "set", + "--no-cone", + *paths, + ], + env=git_env, + ) + _run_git(["git", "-C", str(clone_dir), "sparse-checkout", "reapply"], env=git_env) + + destination_root = workspace_path / checkout_path + if destination_root.exists(): + shutil.rmtree(destination_root) + destination_root.mkdir(parents=True, exist_ok=True) + for rel_path in paths: + src = clone_dir / rel_path + dst = destination_root / rel_path + if src.is_dir(): + shutil.copytree(src, dst, dirs_exist_ok=True) + elif src.is_file(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + else: + print( + f"warning: path '{rel_path}' not found in {repo}@{ref}", + file=sys.stderr, + ) + finally: + shutil.rmtree(clone_parent, ignore_errors=True) + + return destination_root + + +def materialize_orchestrator_skill( + workspace: str | Path = ".", + *, + pack_override: str | None = None, + enabled_override: bool | None = None, + token: str | None = None, +) -> Path | None: + """Validate and materialize exported Orchestrator skill context into `.reference/`.""" + orchestrator_skill = _load_orchestrator_skill_module() + workspace_path = Path(workspace).resolve() + plan = orchestrator_skill.resolve_orchestrator_skill_plan( + workspace_path, + pack_override=pack_override, + enabled_override=enabled_override, + ) + if plan is None: + return None + + if plan.pack: + materialize_reference_packs( + workspace_path, + reference_pack_name=plan.pack, + token=token, + ) + reference_packs = _load_reference_packs_module() + snapshot = reference_packs.load_reference_packs(workspace_path) + matching = [ + entry + for entry in reference_packs.build_checkout_plan(snapshot.packs) + if entry.name == plan.pack + ] + if not matching: + raise ValueError(f"orchestrator skill reference pack not found: {plan.pack}") + checkout_path = workspace_path / matching[0].checkout_path + else: + checkout_path = _materialize_single_checkout_plan( + workspace_path, + repo=plan.repo, + ref=plan.ref, + paths=plan.paths, + checkout_path=plan.checkout_path, + token=token, + ) + + return orchestrator_skill.write_orchestrator_skill_summary( + workspace_path, + checkout_path, + pack_name=plan.pack, + ) + + def assemble_prompt( reference_pack_name: str | None, context: dict[str, Any], provider: str ) -> RunnerPrompt: @@ -257,11 +404,20 @@ def assemble_prompt( if not base_prompt.is_file(): raise FileNotFoundError(f"base prompt file not found: {base_prompt}") + token = context.get("github_token") or context.get("token") if context.get("materialize_reference_packs"): materialize_reference_packs( workspace, reference_pack_name=reference_pack_name, - token=context.get("github_token") or context.get("token"), + token=token, + ) + + if context.get("materialize_orchestrator_skill"): + materialize_orchestrator_skill( + workspace, + pack_override=context.get("orchestrator_skill_pack") or None, + enabled_override=context.get("orchestrator_skill_enabled"), + token=token, ) output_file = str( @@ -292,6 +448,15 @@ def assemble_prompt( if reference_summary.is_file(): parts.extend(["\n\n## Reference Packs\n", _read_text(reference_summary).rstrip()]) + orchestrator_summary = workspace / ".reference" / "ORCHESTRATOR_SKILL.md" + if orchestrator_summary.is_file(): + parts.extend( + [ + "\n\n## Orchestrator Skill Context\n", + _read_text(orchestrator_summary).rstrip(), + ] + ) + text = "".join(parts).rstrip() + "\n" output_path.write_text(text, encoding="utf-8") return RunnerPrompt( @@ -754,6 +919,17 @@ def _write_github_output(outputs: dict[str, str]) -> None: handle.write(f"{key}={_github_output_value(value)}\n") +def _parse_optional_bool(raw: str) -> bool | None: + normalized = (raw or "").strip().lower() + if not normalized: + return None + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError("orchestrator skill enabled override must be true or false") + + def _cmd_assemble(args: argparse.Namespace) -> int: context = { "workspace": args.workspace, @@ -764,6 +940,9 @@ def _cmd_assemble(args: argparse.Namespace) -> int: "output_file": args.output, "task_appendix_file": args.task_appendix_file, "materialize_reference_packs": args.materialize_reference_packs, + "materialize_orchestrator_skill": args.materialize_orchestrator_skill, + "orchestrator_skill_pack": args.orchestrator_skill_pack or None, + "orchestrator_skill_enabled": _parse_optional_bool(args.orchestrator_skill_enabled), "github_token": os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN"), } prompt = assemble_prompt(args.reference_pack_name, context, args.provider) @@ -860,6 +1039,9 @@ def build_parser() -> argparse.ArgumentParser: assemble.add_argument("--task-appendix-file", default="") assemble.add_argument("--reference-pack-name", default="") assemble.add_argument("--materialize-reference-packs", action="store_true") + assemble.add_argument("--orchestrator-skill-pack", default="") + assemble.add_argument("--orchestrator-skill-enabled", default="") + assemble.add_argument("--materialize-orchestrator-skill", action="store_true") assemble.set_defaults(func=_cmd_assemble) parse = subparsers.add_parser("parse-output", help="parse provider output")