From 7d65d94dd89b9110312f85f35726744072d975e3 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 13 Oct 2026 21:31:53 +0200 Subject: [PATCH 1/4] feat(multibot): ship neutral role resources through the core package Extend the existing system-agent contract with three hashed role blueprints. Ship Windows/POSIX starters, the role prompt and care/probe modules in wheel/sdist. Reuse optional TASKPLAN for launching; missing dependency fails closed. Keep runtime roots, accounts, authentication and full access explicit instance opt-ins. Generated-with: Codex GPT-6 Sol --- .gitattributes | 3 + CHANGELOG.md | 8 + README.md | 22 ++ README_de.md | 32 +++ pyproject.toml | 3 +- src/githubbot_core/cli.py | 19 ++ src/githubbot_core/multibot.example.json | 13 + src/githubbot_core/multibot.py | 110 ++++++++ src/githubbot_core/multibot_care.py | 258 ++++++++++++++++++ src/githubbot_core/multibot_probe.py | 98 +++++++ src/githubbot_core/prompts/MULTI-BOT.de.md | 113 ++++++++ .../starters/posix/start-multi-bot.sh | 4 + .../starters/windows/MULTI-BOT.bat | 6 + .../windows/providers/MULTI-BOT-AGY.bat | 4 + .../windows/providers/MULTI-BOT-CLAUDE.bat | 4 + .../windows/providers/MULTI-BOT-CODEX.bat | 4 + .../windows/providers/MULTI-BOT-KIMI.bat | 4 + src/githubbot_core/system_agent.json | 118 +++++++- tests/test_consumer_install.py | 17 ++ tests/test_multibot_resources.py | 92 +++++++ tests/test_system_agent.py | 5 + 21 files changed, 933 insertions(+), 4 deletions(-) create mode 100644 .gitattributes create mode 100644 src/githubbot_core/multibot.example.json create mode 100644 src/githubbot_core/multibot.py create mode 100644 src/githubbot_core/multibot_care.py create mode 100644 src/githubbot_core/multibot_probe.py create mode 100644 src/githubbot_core/prompts/MULTI-BOT.de.md create mode 100644 src/githubbot_core/starters/posix/start-multi-bot.sh create mode 100644 src/githubbot_core/starters/windows/MULTI-BOT.bat create mode 100644 src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat create mode 100644 src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat create mode 100644 src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat create mode 100644 src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat create mode 100644 tests/test_multibot_resources.py diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eb378b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +src/githubbot_core/starters/posix/*.sh text eol=lf +src/githubbot_core/starters/windows/*.bat text eol=crlf +src/githubbot_core/starters/windows/providers/*.bat text eol=crlf diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6d138..a469c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,3 +47,11 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers - Credential-Adapter (Umgebung, `gh auth token`) mit Schwärzung / credential adapter with redaction. - Namensraumunabhängiges Exact-SHA-Review-Gate samt Workflow-Scaffold / namespace-independent exact-SHA review gate with workflow scaffold. + + +## Unreleased + +- Ship neutral MULTI-BOT Windows/POSIX starters, German prompt and runtime helpers in wheel/sdist. +- Extend the existing system-agent contract with three hashed roles without changing its main blueprint. +- Delegate optional provider launching to TASKPLAN; missing dependency fails closed with an install hint. +- Keep roots, accounts, authentication and full-access settings in explicit private instance configuration. diff --git a/README.md b/README.md index 7fda44c..e3d0fec 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,25 @@ No third-party code is included; see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICEN ## License [MIT](LICENSE) + + +## Optional MULTI-BOT role + +`python -m githubbot_core multi-bot` delegates mode/provider/model/effort selection to the +optional TASKPLAN launcher. Modes: `triage` (one shot), `review-merge` (independent reviews), +`aftercare` (reviews plus conservative care). Missing TASKPLAN produces an installation hint +and starts no provider. `--dry-run` enables TASKPLAN's no-probe dry run. + +Wheels and sdists ship `githubbot_core/prompts/MULTI-BOT.de.md`, neutral starters under +`githubbot_core/starters/windows` and `starters/posix`, and the care/probe helper modules. +`githubbot-core --json system-agent` adds three hashed roles while preserving the existing +main blueprint/hash contract. Runtime root defaults to the current directory. Merge is +disabled and full access is not forced by public defaults. Explicit `--config` layers may +set `multi_bot.root`, `instance_prompt`, `merge_account`, `merge_enabled`, and +`trusted_automation`. The configured action permission ceiling remains binding. + +The starter never searches hidden credential paths. Accounts, authentication, slots and +host roots belong to private instance configuration. `multi-bot-care --root PATH next` +and `multi-bot-probe --root PATH` require explicit registry/org/host-log data and fail +closed when absent. Private runtime/extensions remain separate consumers; no background +service or executable automatic merge is installed. diff --git a/README_de.md b/README_de.md index c8ec86e..d134adc 100644 --- a/README_de.md +++ b/README_de.md @@ -183,3 +183,35 @@ Historie aufgebaut. Es ist kein Fremdcode enthalten; siehe ## Lizenz [MIT](LICENSE) + + +## MULTI-BOT als optionale Agentenrolle + +`python -m githubbot_core multi-bot` startet die paketierte Rolle über TASKPLAN. Modus 1 +(`triage`) erfasst Akutes einmalig, Modus 2 (`review-merge`) wiederholt unabhängige Reviews, +Modus 3 (`aftercare`) ergänzt Pflege. Das Startcenter kann denselben CLI-Aufruf benutzen. +`--provider`, `--model`, `--effort` und `--mode` wählen ausdrücklich; die Provider-Ausführung +bleibt in TASKPLAN. Fehlt dieses optionale Modul, erscheint ein Installationshinweis und +kein Anbieter startet. `--dry-run` aktiviert TASKPLANs Trockenlauf ohne Sonde. + +Wheel und sdist enthalten den deutschen Prompt, neutrale Windows-/POSIX-Starter und die +Care-/Probe-Helfer. Ressourcen: `githubbot_core/prompts/MULTI-BOT.de.md`, +`githubbot_core/starters/windows/MULTI-BOT.bat`, `starters/windows/providers/`, +`starters/posix/start-multi-bot.sh`. Auf POSIX funktioniert `sh start-multi-bot.sh` auch +bei verlorenem Ausführungsrecht. Systemprofil: `githubbot-core --json system-agent` +exportiert zusätzlich drei Rollen mit Blueprint- und Prompt-Hash; der bisherige +Standard-Blueprint bleibt kompatibel. + +Standardmäßig ist der Betriebsroot das aktuelle Verzeichnis, Merge deaktiviert und +Vollzugriff nicht erzwungen. Explizite private Konfiguration: `multi_bot.root`, +`instance_prompt`, `merge_account`, `merge_enabled`, optional `trusted_automation` in +`--config DATEI`; `MULTIBOT_ROOT`/`MULTIBOT_INSTANCE_PROMPT` sind entsprechende Overrides. +Der Starter lädt keine Tokens aus einem festen Credential-Ordner. Konten, Auth, Slots und +Hostpfade gehören in die Instanz, niemals in die neutrale Vorlage. Core-Permissions können +weiterhin nur eingeschränkt werden; der Rollenprompt erweitert die Aktionsrechte nicht. + +`githubbot-core multi-bot-care --root PFAD next` und `multi-bot-probe --root PFAD` lesen +explizite Instanzdaten (`config/repo_registry.json`, `config/orgs.json`, Hostlogs). Ohne +Daten melden sie HOLD. Claims ersetzen keine Projektsperren; keine ausführbare Merge-Automatik +oder neue Hintergrundroutine wird installiert. Private Extensions und private Runtime +bleiben eigenständige, getrennte Konsumenten. diff --git a/pyproject.toml b/pyproject.toml index 10474d5..f991d02 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,8 @@ version = { attr = "githubbot_core.__version__" } where = ["src"] [tool.setuptools.package-data] -githubbot_core = ["default_config.json", "system_agent.json", "templates/*.yml"] +githubbot_core = ["default_config.json", "system_agent.json", "multibot.example.json", "templates/*.yml", "prompts/*.md", + "starters/windows/*.bat", "starters/windows/providers/*.bat", "starters/posix/*.sh"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index d8ebf3b..678381d 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -146,6 +146,13 @@ def template_sha256(schema: str, template_id: str, blueprint: dict) -> str: def cmd_system_agent(args, config, transport) -> int: template = json.loads(resources.files("githubbot_core").joinpath("system_agent.json").read_text(encoding="utf-8")) schema = template["_meta"]["schema"] + roles = [] + for raw in template.get("roles", []): + role = dict(raw) + role["sha256"] = template_sha256(schema, role["id"], role["blueprint"]) + prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_bytes() + role["prompt_sha256"] = hashlib.sha256(prompt).hexdigest() + roles.append(role) data = { "schema": schema, "id": template["id"], @@ -154,6 +161,7 @@ def cmd_system_agent(args, config, transport) -> int: "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, "blueprint": template["blueprint"], "permissions": config.get("permissions", {}), + "roles": roles, } text = f"system agent {data['id']} {data['version']} sha256 {data['sha256']}" _emit(args, "system-agent", True, "read", data, [], text) @@ -390,6 +398,8 @@ def add_apply_flags(p): p.set_defaults(func=cmd_gate_scaffold) sub.add_parser("review-gate", help="signed exact-SHA receipts (see: review-gate --help)", add_help=False) + for name in ("multi-bot", "multi-bot-care", "multi-bot-probe"): + sub.add_parser(name, help="packaged MULTI-BOT role and runtime helpers", add_help=False) return parser @@ -408,6 +418,15 @@ def _subcommand_index(argv: list[str]) -> Optional[int]: def main(argv: Optional[list[str]] = None, transport=None) -> int: argv = list(sys.argv[1:] if argv is None else argv) index = _subcommand_index(argv) + if index is not None and argv[index] in {"multi-bot", "multi-bot-care", "multi-bot-probe"}: + from . import multibot, multibot_care, multibot_probe + + entry = { + "multi-bot": multibot.main, + "multi-bot-care": multibot_care.main, + "multi-bot-probe": multibot_probe.main, + }[argv[index]] + return entry(argv[:index] + argv[index + 1 :]) if index is not None and argv[index] == "review-gate": from . import review_gate diff --git a/src/githubbot_core/multibot.example.json b/src/githubbot_core/multibot.example.json new file mode 100644 index 0000000..9d6d630 --- /dev/null +++ b/src/githubbot_core/multibot.example.json @@ -0,0 +1,13 @@ +{ + "multi_bot": { + "merge_enabled": false, + "merge_account": "", + "trusted_automation": false, + "mode": "triage", + "provider": "", + "model": "", + "effort": "", + "root": "", + "instance_prompt": "" + } +} diff --git a/src/githubbot_core/multibot.py b/src/githubbot_core/multibot.py new file mode 100644 index 0000000..6d1c403 --- /dev/null +++ b/src/githubbot_core/multibot.py @@ -0,0 +1,110 @@ +"""Packaged MULTI-BOT launcher; provider execution remains in optional TASKPLAN.""" + +from __future__ import annotations + +import argparse +from importlib import resources +import os +from pathlib import Path +import sys + +from .config import ConfigError, load_config + +MODES = ("triage", "review-merge", "aftercare") + + +def choose_mode(value="", *, ask=input, chooser=None): + value = value.strip().lower() + if value in ("1", "2", "3"): + value = MODES[int(value) - 1] + if value: + if value not in MODES: + raise ValueError(f"Unknown mode: {value}") + return value + if chooser is None: + from taskplan.launcher import _ask_one + + chooser = _ask_one + return chooser(ask, "Mode", MODES[0], MODES, strict=True) or MODES[0] + + +def main(argv=None, *, launch=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", action="append", default=[], type=Path) + parser.add_argument("--root", type=Path, default=os.environ.get("MULTIBOT_ROOT") or None) + parser.add_argument("--mode", default="") + parser.add_argument("--provider", default="") + parser.add_argument("--model", default="") + parser.add_argument("--effort", default="") + parser.add_argument("--request", default="") + parser.add_argument("--instance-prompt", type=Path, default=os.environ.get("MULTIBOT_INSTANCE_PROMPT") or None) + parser.add_argument("--no-interactive", action="store_true") + parser.add_argument("--no-fallback", action="store_true") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args(argv) + try: + config = load_config(args.config) + settings = config.get("multi_bot", {}) + if not isinstance(settings, dict): + raise ValueError("multi_bot configuration must be an object") + root = (args.root or Path(settings.get("root") or Path.cwd())).expanduser().resolve() + if not root.is_dir(): + raise ValueError("Configured runtime root is not a directory") + if (root / "STOP.MULTI-BOT").exists(): + print("[STOP] STOP.MULTI-BOT exists; no provider launched.") + return 0 + instance = args.instance_prompt or settings.get("instance_prompt") + if instance: + instance = Path(instance).expanduser().resolve() + if not instance.is_file(): + raise ValueError("Configured instance prompt is missing") + env = dict(os.environ) + env["TASKPLAN_WORKDIR"] = str(root) + # Public defaults never force full access. Existing explicit instance ENV is respected. + if "trusted_automation" in settings: + if type(settings["trusted_automation"]) is not bool: + raise ValueError("trusted_automation must be a boolean") + env["TASKPLAN_TRUSTED_AUTOMATION"] = "1" if settings["trusted_automation"] else "0" + if args.dry_run: + env["TASKPLAN_STARTER_DRY_RUN"] = "1" + env["TASKPLAN_STARTER_PROBE"] = "0" + if launch is None: + from taskplan.launcher import launch + mode = choose_mode(args.mode or settings.get("mode", "") or ("triage" if args.no_interactive else "")) + request = f"Start MULTI-BOT in mode {mode}. Read the complete packaged role prompt." + request += " Configured merge account: " + (str(settings.get("merge_account", "")) or "none") + "." + request += " Merge enabled: " + str(settings.get("merge_enabled") is True).lower() + "." + if instance: + request += f" Read the explicitly configured local instance prompt {instance}." + if args.request: + request += " " + args.request + prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md") + with resources.as_file(prompt) as prompt_file: + return launch( + "", + label="multi-bot", + prompt_file=str(prompt_file), + request=request, + provider=args.provider or settings.get("provider", ""), + model=args.model or settings.get("model", ""), + effort=args.effort or settings.get("effort", ""), + env=env, + interactive=not args.no_interactive, + fallback=not args.no_fallback, + session_name="MULTI-BOT", + ) + except ImportError: + print( + "[HOLD] Optional TASKPLAN launcher missing. Install taskplan in this Python " + "environment (python -m pip install taskplan), then run python -m taskplan doctor. " + "No provider launched.", + file=sys.stderr, + ) + return 1 + except (ConfigError, ValueError, OSError) as exc: + print(f"[HOLD] {exc}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/githubbot_core/multibot_care.py b/src/githubbot_core/multibot_care.py new file mode 100644 index 0000000..ec3f659 --- /dev/null +++ b/src/githubbot_core/multibot_care.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Host-local care ledger, conservative leases and oldest-first selection.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import re +import socket +import sys +import uuid + +UTC = timezone.utc +MAX_TTL = 86400 + + +def timestamp(value): + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError("Zeitstempel ohne Zeitzone") + return parsed.astimezone(UTC) + + +def host_name(value=None): + value = value or os.environ.get("COMPUTERNAME") or socket.gethostname() + if not re.fullmatch(r"[A-Za-z0-9_.-]+", value) or value in (".", ".."): + raise ValueError("Ungültiger Hostname") + return value + + +def load_json(path): + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def targets(root): + registry = load_json(root / "config/repo_registry.json")["repos"] + config = load_json(root / "config/orgs.json") + owners = {x["name"] for x in config["organizations"] if x.get("enabled", True)} + owners.update(x["name"] for x in config.get("personal_namespaces", [])) + result = {} + for repo, entry in registry.items(): + if repo.split("/")[0] not in owners: + continue + gh = entry.get("github") or {} + visibility = entry.get("visibility_override") or gh.get("visibility") + if visibility != "public" or gh.get("archived") is not False or gh.get("fork") is not False: + continue + result[repo] = entry + # Profile candidates are explicit but unknown visibility is never assumed public. + return result + + +def events(root): + rows = [] + log = root / "_log" + paths = sorted((log / "hosts").glob("*/CARE-LOG*.jsonl")) + paths += sorted((log / "archive").glob("*/CARE-LOG*.jsonl")) + paths += sorted((log / "conflicts").glob("*/*CARE-LOG*.jsonl")) + for path in paths: + for index, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1): + if not line.strip(): + continue + try: + row = json.loads(line) + if row["event"] not in {"claim", "care"}: + raise ValueError("Unbekanntes Ereignis") + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", row["repo"]): + raise ValueError("Ungültiges Repo") + timestamp(row["at"]) + host_name(row["host"]) + if not row.get("claim_id"): + raise ValueError("Claim-ID fehlt") + if row["event"] == "claim": + expires = timestamp(row["expires_at"]) + duration = (expires - timestamp(row["at"])).total_seconds() + if not 0 < duration <= MAX_TTL: + raise ValueError("Ungültige TTL") + elif row.get("result") not in {"ok", "failed", "skipped"}: + raise ValueError("Ungültiges Ergebnis") + rows.append(row) + except (ValueError, KeyError, TypeError) as exc: + raise ValueError(f"Unlesbarer Care-Beleg {path}:{index}: {exc}") from exc + return rows + + +def active_claims(rows, now): + # Only a matching owner may close a lease. Foreign-host receipts cannot release it. + closed = {(r["claim_id"], r["host"], r["repo"]) for r in rows if r["event"] == "care"} + return [ + r + for r in rows + if r["event"] == "claim" + and (r["claim_id"], r["host"], r["repo"]) not in closed + and timestamp(r["expires_at"]) > now + ] + + +def legacy_dates(root, candidates, host): + dates = {} + # Historical run directories often contain only repo-name, so ambiguous names are ignored. + names = {} + for repo in candidates: + names.setdefault(repo.split("/")[1].lower(), []).append(repo) + logs = root / "logs" + for path in logs.iterdir() if logs.exists() else []: + match = re.fullmatch(r"(\d{4}-\d{2}-\d{2})-(.+?)-(?:full-|surface-)?care", path.name) + if match and path.is_dir(): + repos = names.get(match[2].lower(), []) + if len(repos) == 1: + at = timestamp(match[1] + "T00:00:00+00:00") + dates[repos[0]] = max(dates.get(repos[0], at), at) + for repo, entry in candidates.items(): + # Foreign host paths are not valid paths on this machine. + for clone in entry.get("hosts", {}).get(host, {}).get("clones", []): + path = Path(clone["path"]) / "_after-care/LOG.md" + if path.is_file(): + for match in re.finditer( + r"^## (\d{4}-\d{2}-\d{2})\s+[^\n]*(?:full|surface)", path.read_text(encoding="utf-8-sig"), re.M + ): + at = timestamp(match[1] + "T00:00:00+00:00") + dates[repo] = max(dates.get(repo, at), at) + return dates + + +def overview(root, *, now=None, host=None): + now = now or datetime.now(UTC) + host = host_name(host) + candidates = targets(root) + rows = events(root) + dates = legacy_dates(root, candidates, host) + for row in rows: + if row["event"] == "care" and row["result"] == "ok": + at = timestamp(row["at"]) + if at > now: + raise ValueError("Care-Beleg liegt in der Zukunft") + dates[row["repo"]] = max(dates.get(row["repo"], at), at) + claimed = {r["repo"] for r in active_claims(rows, now)} + return sorted( + [ + {"repo": repo, "last_care": dates[repo].isoformat() if repo in dates else None, "claimed": repo in claimed} + for repo in candidates + ], + key=lambda r: (r["last_care"] is not None, r["last_care"] or "", r["repo"]), + ) + + +def append_event(root, host, row): + path = root / "_log/hosts" / host / "CARE-LOG.jsonl" + path.parent.mkdir(parents=True, exist_ok=True) + # Exclusive per-host writer guard; stale guards are deliberately not auto-deleted. + guard = path.with_suffix(".writer") + fd = os.open(guard, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + try: + os.close(fd) + with path.open("a", encoding="utf-8", newline="\n") as stream: + stream.write(json.dumps(row, ensure_ascii=False) + "\n") + stream.flush() + os.fsync(stream.fileno()) + finally: + guard.unlink() + + +def claim(root, repo, *, host=None, ttl=3600, now=None): + host = host_name(host) + now = now or datetime.now(UTC) + if not 1 <= ttl <= MAX_TTL: + raise ValueError("TTL muss 1 bis 86400 Sekunden betragen") + if repo not in targets(root): + raise ValueError("Ziel ist kein bekanntes eigenes öffentliches Care-Repo") + if any(r["repo"] == repo for r in active_claims(events(root), now)): + raise ValueError("Ziel bereits beansprucht") + row = { + "event": "claim", + "repo": repo, + "host": host, + "at": now.isoformat(), + "expires_at": (now + timedelta(seconds=ttl)).isoformat(), + "claim_id": uuid.uuid4().hex, + } + append_event(root, host, row) + # Detect a second claim visible after append. OneDrive replication still requires project locks. + if len([r for r in active_claims(events(root), now) if r["repo"] == repo]) != 1: + raise ValueError("Konkurrierender Claim; Projekt nicht bearbeiten") + return row + + +def record(root, claim_id, *, host=None, kind="surface", model="", result="ok", evidence="", now=None): + host = host_name(host) + now = now or datetime.now(UTC) + leases = [r for r in active_claims(events(root), now) if r["claim_id"] == claim_id] + if len(leases) != 1 or leases[0]["host"] != host: + raise ValueError("Eigener aktiver Claim fehlt") + if any(r["repo"] == leases[0]["repo"] and r["claim_id"] != claim_id for r in active_claims(events(root), now)): + raise ValueError("Konkurrierender Claim") + if kind not in {"full", "surface"} or result not in {"ok", "failed", "skipped"}: + raise ValueError("Ungültige Art oder Ergebnis") + if result == "ok" and (not model.strip() or not evidence.strip()): + raise ValueError("Erfolgsbeleg braucht Modell und PR/Commit/Artefakt") + row = { + "event": "care", + "repo": leases[0]["repo"], + "host": host, + "at": now.isoformat(), + "claim_id": claim_id, + "kind": kind, + "model": model, + "result": result, + "evidence": evidence, + } + append_event(root, host, row) + return row + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--host", default=None) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("next") + commands.add_parser("overview") + c = commands.add_parser("claim") + c.add_argument("--repo", required=True) + c.add_argument("--ttl", type=int, default=3600) + r = commands.add_parser("record") + r.add_argument("--claim-id", required=True) + r.add_argument("--kind", choices=("full", "surface"), default="surface") + r.add_argument("--model", default="") + r.add_argument("--result", choices=("ok", "failed", "skipped"), default="ok") + r.add_argument("--evidence", default="") + args = parser.parse_args(argv) + try: + if args.command in {"next", "overview"}: + rows = overview(args.root, host=args.host) + result = next((r for r in rows if not r["claimed"]), None) if args.command == "next" else rows + elif args.command == "claim": + result = claim(args.root, args.repo, host=args.host, ttl=args.ttl) + else: + result = record( + args.root, + args.claim_id, + host=args.host, + kind=args.kind, + model=args.model, + result=args.result, + evidence=args.evidence, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (ValueError, OSError, KeyError, TypeError) as exc: + print(f"[HOLD] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/githubbot_core/multibot_probe.py b/src/githubbot_core/multibot_probe.py new file mode 100644 index 0000000..df2756f --- /dev/null +++ b/src/githubbot_core/multibot_probe.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""One-shot read-only GitHub inventory; never runs agents or applies fixes.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import subprocess +import sys + + +def query(args, *, run=subprocess.run): + result = run(["gh", *args], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=90) + if result.returncode: + # Do not forward arbitrary CLI stderr (may contain credential configuration). + return {"ok": False, "exit_code": result.returncode, "error": "GitHub-Abfrage fehlgeschlagen"} + try: + return {"ok": True, "data": json.loads(result.stdout)} + except ValueError: + return {"ok": False, "error": "Ungültige JSON-Antwort"} + + +def probe(root, *, memberships=False, account="", run=subprocess.run): + config = json.loads((root / "config/orgs.json").read_text(encoding="utf-8-sig")) + orgs = [ + x["name"] + for x in config["organizations"] + if x.get("enabled", True) and x.get("type", "organization") == "organization" + ] + owners = [*orgs, *[x["name"] for x in config.get("personal_namespaces", [])]] + results = {} + for kind in ("prs", "issues"): + results[kind] = query( + [ + "search", + kind, + "--owner", + ",".join(owners), + "--state", + "open", + "--limit", + "1000", + "--json", + "number,title,repository,url", + ], + run=run, + ) + if results[kind].get("ok") and len(results[kind]["data"]) >= 1000: + results[kind]["coverage"] = "possibly-truncated" + if memberships: + account = account or config.get("multi_bot", {}).get("merge_account", "") + if not account or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9-]{0,38}", account): + raise ValueError("Mitgliedschaftsprüfung braucht --account oder multi_bot.merge_account") + results["bot_membership"] = {org: query(["api", f"orgs/{org}/memberships/{account}"], run=run) for org in orgs} + return results + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd()) + parser.add_argument("--memberships", action="store_true") + parser.add_argument( + "--account", default=os.environ.get("MULTIBOT_MERGE_ACCOUNT", ""), help="Configured merge account to inspect" + ) + parser.add_argument("--output", type=Path, help="Optional local evidence JSON") + args = parser.parse_args(argv) + try: + result = probe(args.root, memberships=args.memberships, account=args.account) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + summary = { + kind: {"ok": result[kind]["ok"], "count": len(result[kind].get("data", []))} + for kind in ("prs", "issues") + } + summary["bot_membership"] = { + org: { + "ok": row["ok"], + "state": row.get("data", {}).get("state"), + "role": row.get("data", {}).get("role"), + } + for org, row in result.get("bot_membership", {}).items() + } + print(json.dumps(summary, ensure_ascii=False, indent=2)) + else: + print(json.dumps(result, ensure_ascii=False, indent=2)) + # Membership 404/403 is unknown, never a false 'not member'. + return 0 if result["prs"]["ok"] and result["issues"]["ok"] else 1 + except (OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc: + print(f"[HOLD] Probe unvollständig ({type(exc).__name__})", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/githubbot_core/prompts/MULTI-BOT.de.md b/src/githubbot_core/prompts/MULTI-BOT.de.md new file mode 100644 index 0000000..d61ad08 --- /dev/null +++ b/src/githubbot_core/prompts/MULTI-BOT.de.md @@ -0,0 +1,113 @@ +# MULTI-BOT — GithubBot-Agent + +Dieser lokale Rollenprompt ist durch den Starter autorisiert. Es gelten die vorhandenen +Projektregeln aus `AGENTS.md`/`CLAUDE.md` und die Betriebsregeln aus `REG.md`, `LOG.md` +und `docs/REGISTER-ABLAGE.md`, sofern vorhanden. Lies sie vollständig. Ein ausdrücklich +konfigurierter Instanzprompt ergänzt Hosts, Root-Pfade, Credentials und konkrete Holds; +diese Daten gehören in private Instanzkonfiguration. Der Startauftrag wählt genau einen +Modus. Keine Unteragenten starten oder zusätzliche kostenpflichtige Dienste bestellen. + +## Gemeinsamer Ablauf und Grenzen + +1. Prüfe vor jeder Runde und jeder Mutation `STOP.MULTI-BOT` im GithubBot-Betriebsroot. + Existenz beendet den Lauf geordnet. Bei Abbruch eigene Claims und Locks freigeben; + fremde Locks bleiben erhalten. Ein Bericht pro Runde in + `_log/hosts//MULTI-BOT-LOG.jsonl`: Zeitpunkt, tatsächliches Modell, Modus, + geprüfte Ziele, Ergebnis, Blocker und Belegpfade. Keine Geheimnisse protokollieren. +2. Stelle den tatsächlichen Arbeitsort fest: Code im kanonischen lokalen Klon, + Betriebsregister/Logs im konfigurierten Betriebsroot; lokale Instanzregeln entscheiden + über Spiegel-/Cloud- und Hostpfade. Ohne belegte Konfiguration keine Pfade erfinden. + Nutze `run.py registry show`, `_tools/repo_radar.py` und die vorhandenen Werkzeuge. + Erfinde keine zweite Registry. `audit_open_prs.py` liefert Hinweise, keine Merge-Freigabe: + seine Empfehlung kann bei Drafts oder unvollständiger CI zu optimistisch sein. +3. Vor Änderungen, Commit, Push und Merge den echten `lock_scan.py --check-dir + --json` über einen aufgelösten Python-Interpreter ausführen: lokaler Klon (bei Worktrees + auch Hauptklon) UND OneDrive-Projektpfad. Unbekannter Status, ungültige JSON-Ausgabe oder + aktive fremde Sperre im Scope bedeutet HOLD. Eigene Arbeit vorher mit `lock_create.py` + exklusiv sperren. Judging-/User-Locks bleiben absolut. Konkrete Holds werden aus den + Projektregeln und der privaten Instanzkonfiguration gelesen. Datum allein hebt nichts auf. +4. `config/orgs.json` und `exclude_writes`/`global_exclude_writes` beachten. Org-Profile + (`owner/.github`) nur als eigenes Care-PR mit unabhängiger Prüfung; keine direkten + Bot-Schreibfeatures dort. Keine Draft- oder gestapelten PRs mergen. Keine Force-Pushes, + Admin-/Bypass-Merges, Publikation, Tags, Releases oder Sichtbarkeitsänderungen. Keine + Dritt-Nachrichten, Einladungen, Follow-Aktionen oder Kommentare ohne explizite Autorisierung. +5. Echte Nutzerentscheidungen mit dem vorhandenen `ticket_writer.py` als Ticket erfassen; + nicht in alte `USER-DECIDE-NEEDED.md`-Wrapper schreiben. Fremde Arbeit nie mitcommitten. + +## Modus triage — Akutes einmalig + +Erfasse einmalig offene Issues/PRs, CI-Fehler und Sicherheitswarnungen über lesende +`gh`-Abfragen. `githubbot-core multi-bot-probe` ist die reine Erfassung ohne GitHub-Mutation. +Prüfe konkrete Probleme mit vorhandenen Health-/PR-Werkzeugen, wobei deren mögliche +Schreibwirkungen vorher zu lesen sind (`run.py triage` kann mutieren). Sortiere akut, +blockiert, später; leite konkrete lokale Aufgaben ab. Kein Merge in diesem Modus. +Der einmalige Bericht beendet die Runde und die Sitzung. + +## Modus review-merge — wiederkehrende Reviews + +Prüfe offene PRs nacheinander, exakt einen PR pro Arbeitseinheit. Eine abgeschlossene +Runde schreibt einen Bericht; bei Leerlauf 60 Sekunden warten, keine heiße Schleife. +Jede neue Runde prüft STOP und Kontingent; fehlende Telemetrie sperrt neue Modellstarts. + +Die Zwei-Modell-Regel ist verbindlich: Autor anhand Commit-Trailern (`Co-authored-by`, +`Generated-with`), PR-Text und Branch-Präfix ermitteln. Widersprüchliche oder fehlende +Autorinformation bedeutet HOLD. Reviewer ist ein anderer Anbieter oder eine andere +Modellklasse; ein neuer Prozess desselben Modells genügt nicht. Das unabhängige Modell +liest den vollständigen Diff im isolierten Worktree und prüft Verhalten, Tests und CI. +Autor-Modell repariert und öffnet PRs; Reviewer darf erst nach belegter Prüfung mergen. +`core/review_gate.py` und `docs/REVIEW_GATE_PILOT.md` nutzen, wenn das Repo den Pilot verlangt; +Receipt muss zum exakten aktuellen `headRefOid` gehören. Niemals eine fehlende Receipt ersetzen. + +Vor Merge: `gh pr view --json headRefOid,baseRefName,headRefName,isDraft,mergeable, +mergeStateStatus,statusCheckRollup,reviewDecision,commits` neu lesen. Default-Base, +keine Stapelung, vollständig grüne erforderliche CI, keine offenen Konflikte oder +Review-Blocker; bei unbekanntem Checkstatus HOLD. Nach jeder Änderung des Heads erneut prüfen. +Merge ausschließlich mit `--match-head-commit ` und ohne `--admin` oder +`--auto`. Wo Branch-Schutz zusätzliche Approvals verlangt, diesen normal erfüllen. + +Merges sind standardmäßig deaktiviert. Nur die ausdrücklich autorisierte Instanzkonfiguration +darf sie aktivieren (`multi_bot.merge_enabled = true` in `config/orgs.json` oder einem +expliziten lokalen Instanzprompt). Das Zweitmodell nutzt das dort konfigurierte Merge-Konto +(`multi_bot.merge_account`, alternativ `MULTIBOT_MERGE_ACCOUNT`); ohne Kontonamen HOLD. +Token ausschließlich über die konfigurierte Laufzeitumgebung als `GH_TOKEN` für den +einen Kindprozess bereitstellen; kein versteckter Credential-Fundort ist vorgegeben. +Token nicht anzeigen, kopieren, in Dateien speichern oder mit `gh auth login` persistieren. +Vor Mutation per `gh api user --jq .login` die Identität prüfen. Fehlende Mitgliedschaft +oder Schreibberechtigung bedeutet HOLD. Nach Merge mit `gh pr view --json state,mergedAt, +mergeCommit,headRefOid` unabhängig zurücklesen; allein Exit 0 ist kein Merge-Beleg. +Ab ungefähr 1000 geänderten Zeilen ist der dokumentierte unabhängige Review erforderlich; +ein GitHub-Kommentar setzt explizite Autorisierung voraus. + +## Modus aftercare — Reviews und Pflege + +Erfülle zuerst review-merge. Wenn keine akute PR-Arbeit wartet, erfrage +`githubbot-core multi-bot-care --root next` und beanspruche genau ein Ziel mit `claim --repo owner/name`. +Auswahl: eigene öffentliche, nicht archivierte Nicht-Fork-Repos und Org-Profile; niemals +private oder fremde Repos. Nie gepflegte zuerst, dann ältester Beleg. Ausgeschlossen/ +gesperrt heißt überspringen, keine Pflege erzwingen. Bestehende `logs/*care/` und +`_after-care/LOG.md` gelten mit. Bei Pfad-A/B-Arbeit ist der Radar samt 24-h-Cooldown zusätzlich +maßgeblich; der Care-Selektor ersetzt seine Locks/Dirty-/Cooldown-Prüfung nicht. + +Care-Claims sind Host-Logs mit TTL und Claim-ID. Vor Arbeit nach OneDrive-Synchronisierung +erneut alle Host-Logs lesen: konkurrierender Claim = HOLD. OneDrive ist kein verteilter +atomarer Lockdienst; ein Claim allein gibt niemals Schreibrecht. Projektsperren bleiben +der verbindliche Schutz. Erfolgreiche Pflege mit `record --claim-id ... --result ok` +dokumentieren; gescheitert/übersprungen mit `failed`/`skipped` beenden, nicht als gepflegt +zählen. Artefakte, PR, Commit und tatsächliches Modell angeben. Nutze die vorhandenen +full-after-care-/surface-after-care-Skills passend; keine Versionsbump-/Release-Automatik. +Pflegeprofile und BACH/Ocean-Blueprints verweisen auf diese drei Abschnitte; deren API- +Registrierung ist getrennt von diesem Starter und bleibt bis zum API-Readback unbestätigt. + +## Paket- und Instanzkonfiguration + +Diese neutrale Rolle wird mit `githubbot-core multi-bot` gestartet. Der vorhandene +Core-CLI-Vertrag liefert Triage, PR-Zusammenfassungen, Health und exakte Review-Receipts. +Private `run.py`-/Radar-/Care-Bestände sind optionale Instanzfähigkeiten: Fehlt eine Fähigkeit, +melde HOLD oder nutze das vorhandene Core-Gegenstück; keine private Pipeline erfinden. +Care-/Probe-Helfer benutzen den ausdrücklich gewählten `--root`, standardmäßig das aktuelle +Verzeichnis. Ihre Dateikonventionen (`config/orgs.json`, `config/repo_registry.json`, Hostlogs) +müssen als Instanzdaten vorhanden sein; bei fehlendem Bestand sperren sie die Auswahl. +Die Core-Konfiguration kommt über wiederholbares `--config PFAD`. `multi_bot` enthält +Root, optionalen Instanzprompt, Merge-Konto, Merge-Opt-in und optional trusted_automation. +Die berechnete Core-Permission-Decke bleibt bindend; ein Rollenprompt erweitert sie nicht. +Ohne Merge-Aktionsfähigkeit/Permission keine Merge-Mutation, auch bei aktivem Merge-Opt-in. diff --git a/src/githubbot_core/starters/posix/start-multi-bot.sh b/src/githubbot_core/starters/posix/start-multi-bot.sh new file mode 100644 index 0000000..51bbea3 --- /dev/null +++ b/src/githubbot_core/starters/posix/start-multi-bot.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu +# No fixed runtime root, account, model, token path or full-access setting. +exec "${PYTHON:-python3}" -m githubbot_core multi-bot "$@" diff --git a/src/githubbot_core/starters/windows/MULTI-BOT.bat b/src/githubbot_core/starters/windows/MULTI-BOT.bat new file mode 100644 index 0000000..577029f --- /dev/null +++ b/src/githubbot_core/starters/windows/MULTI-BOT.bat @@ -0,0 +1,6 @@ +@echo off +setlocal +set "PYTHONIOENCODING=utf-8" +REM Runtime roots and full-access opt-in come from --config or caller environment. +python -m githubbot_core multi-bot %* +exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat new file mode 100644 index 0000000..70c2315 --- /dev/null +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +python -m githubbot_core multi-bot --provider agy --no-interactive %* +exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat new file mode 100644 index 0000000..8bff88a --- /dev/null +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +python -m githubbot_core multi-bot --provider claude --no-interactive %* +exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat new file mode 100644 index 0000000..1ec9938 --- /dev/null +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +python -m githubbot_core multi-bot --provider codex --no-interactive %* +exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat new file mode 100644 index 0000000..e0753b5 --- /dev/null +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat @@ -0,0 +1,4 @@ +@echo off +setlocal +python -m githubbot_core multi-bot --provider kimi --no-interactive %* +exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/system_agent.json b/src/githubbot_core/system_agent.json index a297fa7..dd571a6 100644 --- a/src/githubbot_core/system_agent.json +++ b/src/githubbot_core/system_agent.json @@ -14,7 +14,119 @@ "skills": [], "animus_type": "cli", "modus": "casualis", - "contractus": {"turns": 15}, - "governance": {"profile": "read_only_research", "tool_whitelist": ["read_files", "search_content"]} - } + "contractus": { + "turns": 15 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + }, + "roles": [ + { + "id": "multi-bot-triage", + "label": "multi-bot", + "mode": "triage", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "triage" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-triage", + "title": "MULTI-BOT triage", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode triage. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "casualis", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-review-merge", + "label": "multi-bot", + "mode": "review-merge", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "review-merge" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-review-merge", + "title": "MULTI-BOT review-merge", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode review-merge. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-aftercare", + "label": "multi-bot", + "mode": "aftercare", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "aftercare" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-aftercare", + "title": "MULTI-BOT aftercare", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode aftercare. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + } + ] } diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py index 9e9d53a..7d9dab8 100644 --- a/tests/test_consumer_install.py +++ b/tests/test_consumer_install.py @@ -48,6 +48,23 @@ def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None agent = _run([str(cli), "--json", "system-agent"], cwd=work) # template ships as package data assert agent.returncode == 0, agent.stderr assert json.loads(agent.stdout)["data"]["blueprint"]["name"] == "githubbot" + assert len(json.loads(agent.stdout)["data"]["roles"]) == 3 + role = _run([str(cli), "multi-bot", "--no-interactive", "--dry-run"], cwd=work) + assert role.returncode == 1 and "pip install taskplan" in role.stderr + assert not list(work.iterdir()) # no private data or provider process created + resource = _run( + [ + str(python), + "-c", + "from importlib import resources; " + "p=resources.files('githubbot_core'); " + "assert p.joinpath('prompts/MULTI-BOT.de.md').is_file(); " + "assert p.joinpath('starters/windows/MULTI-BOT.bat').is_file(); " + "assert p.joinpath('starters/posix/start-multi-bot.sh').is_file()", + ], + cwd=work, + ) + assert resource.returncode == 0, resource.stderr origin = _run([str(python), "-c", "import githubbot_core, sys; print(githubbot_core.__file__)"], cwd=work) assert "site-packages" in origin.stdout, origin.stdout # not the source tree diff --git a/tests/test_multibot_resources.py b/tests/test_multibot_resources.py new file mode 100644 index 0000000..43aa09f --- /dev/null +++ b/tests/test_multibot_resources.py @@ -0,0 +1,92 @@ +"""Packaged entrypoints, authorization defaults and external launcher boundary.""" + +import hashlib +from importlib import resources +import json +import sys +from unittest.mock import Mock, patch + +from githubbot_core import cli, multibot + + +def test_system_agent_extends_compatible_template_with_verified_roles(capsys): + assert cli.main(["--json", "system-agent"]) == 0 + data = json.loads(capsys.readouterr().out)["data"] + assert data["id"] == "githubbot" and data["sha256"].startswith("2e72c000") + assert [r["mode"] for r in data["roles"]] == list(multibot.MODES) + prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_bytes() + for role in data["roles"]: + assert role["sha256"] == cli.template_sha256(data["schema"], role["id"], role["blueprint"]) + assert role["prompt_sha256"] == hashlib.sha256(prompt).hexdigest() + assert role["blueprint"]["governance"]["profile"] == "read_only_research" + + +def test_packaged_resources_are_neutral_and_ascii_starters(): + base = resources.files("githubbot_core") + paths = [ + "starters/windows/MULTI-BOT.bat", + "starters/posix/start-multi-bot.sh", + *[f"starters/windows/providers/MULTI-BOT-{p}.bat" for p in ("CLAUDE", "CODEX", "AGY", "KIMI")], + ] + for path in paths: + text = base.joinpath(path).read_text(encoding="ascii") + assert "githubbot_core multi-bot" in text + assert "TRUSTED_AUTOMATION=1" not in text and "OneDrive" not in text + prompt = base.joinpath("prompts/MULTI-BOT.de.md").read_text(encoding="utf-8") + assert "ellmos-multi-bot" not in prompt and "lukisch" not in prompt and "C:\\Users" not in prompt + + +def test_empty_instance_launch_has_no_trusted_automation_or_identity(monkeypatch, tmp_path): + monkeypatch.delenv("TASKPLAN_TRUSTED_AUTOMATION", raising=False) + launch = Mock(return_value=0) + assert multibot.main(["--root", str(tmp_path), "--mode", "1", "--no-interactive", "--dry-run"], launch=launch) == 0 + args = launch.call_args.kwargs + assert args["label"] == "multi-bot" and args["provider"] == "" + assert args["env"]["TASKPLAN_WORKDIR"] == str(tmp_path.resolve()) + assert "TASKPLAN_TRUSTED_AUTOMATION" not in args["env"] + assert args["env"]["TASKPLAN_STARTER_DRY_RUN"] == "1" + assert "Merge enabled: false" in args["request"] and "merge account: none" in args["request"] + + +def test_instance_config_can_explicitly_opt_in(tmp_path): + prompt = tmp_path / "instance.md" + prompt.write_text("Authorized local rules", encoding="utf-8") + config = tmp_path / "instance.json" + config.write_text( + json.dumps( + { + "multi_bot": { + "root": str(tmp_path), + "instance_prompt": str(prompt), + "merge_account": "example-reviewer", + "merge_enabled": True, + "trusted_automation": True, + } + } + ) + ) + launch = Mock(return_value=0) + assert multibot.main(["--config", str(config), "--mode", "2", "--no-interactive"], launch=launch) == 0 + args = launch.call_args.kwargs + assert args["env"]["TASKPLAN_TRUSTED_AUTOMATION"] == "1" + assert "example-reviewer" in args["request"] and "Merge enabled: true" in args["request"] + assert str(prompt) in args["request"] + + +def test_missing_optional_launcher_is_explicit_and_fails_closed(capsys, tmp_path): + with patch.dict(sys.modules, {"taskplan": None, "taskplan.launcher": None}): + assert cli.main(["multi-bot", "--root", str(tmp_path), "--mode", "triage", "--no-interactive"]) == 1 + assert "pip install taskplan" in capsys.readouterr().err + + +def test_stop_file_is_checked_without_optional_launcher(tmp_path): + (tmp_path / "STOP.MULTI-BOT").touch() + with patch.dict(sys.modules, {"taskplan": None, "taskplan.launcher": None}): + assert multibot.main(["--root", str(tmp_path), "--no-interactive"]) == 0 + + +def test_missing_private_data_helpers_fail_without_writes(tmp_path, capsys): + assert cli.main(["multi-bot-care", "--root", str(tmp_path), "next"]) == 1 + assert cli.main(["multi-bot-probe", "--root", str(tmp_path)]) == 1 + assert not list(tmp_path.iterdir()) + capsys.readouterr() diff --git a/tests/test_system_agent.py b/tests/test_system_agent.py index b460879..477a870 100644 --- a/tests/test_system_agent.py +++ b/tests/test_system_agent.py @@ -75,6 +75,11 @@ def test_hash_ignores_version_and_meta(capsys, monkeypatch, tmp_path): template = json.loads(resources.files("githubbot_core").joinpath("system_agent.json").read_text(encoding="utf-8")) template["_meta"]["description"] += " (changed)" (tmp_path / "system_agent.json").write_text(json.dumps(template), encoding="utf-8") + (tmp_path / "prompts").mkdir() + (tmp_path / "prompts/MULTI-BOT.de.md").write_text( + resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_text(encoding="utf-8"), + encoding="utf-8", + ) monkeypatch.setattr(cli, "resources", SimpleNamespace(files=lambda _package: tmp_path)) monkeypatch.setattr(cli, "__version__", "9.9.9") data = _system_agent(capsys) From 5872068f3236348269c5c2ede7a155a08dfd9f0b Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 13 Oct 2026 21:38:58 +0200 Subject: [PATCH 2/4] fix(multibot): preserve the strict system-agent descriptor contract Export MULTI-BOT roles through a separate resource and multi-bot-role CLI command. Keep the existing system-agent response and original template unchanged. Pin the exact permitted descriptor fields in unit and fresh-install consumer tests. Generated-with: Codex GPT-6 Sol --- CHANGELOG.md | 2 +- README.md | 5 +- README_de.md | 7 +- pyproject.toml | 2 +- src/githubbot_core/cli.py | 32 +++++-- src/githubbot_core/multibot_roles.json | 110 +++++++++++++++++++++++ src/githubbot_core/system_agent.json | 118 +------------------------ tests/test_consumer_install.py | 5 +- tests/test_multibot_resources.py | 15 ++-- tests/test_system_agent.py | 5 -- 10 files changed, 159 insertions(+), 142 deletions(-) create mode 100644 src/githubbot_core/multibot_roles.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a469c6d..827ac84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,6 @@ Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), [Semantic Vers ## Unreleased - Ship neutral MULTI-BOT Windows/POSIX starters, German prompt and runtime helpers in wheel/sdist. -- Extend the existing system-agent contract with three hashed roles without changing its main blueprint. +- Export separate MULTI-BOT role descriptors using the strict existing schema; preserve system-agent unchanged. - Delegate optional provider launching to TASKPLAN; missing dependency fails closed with an install hint. - Keep roots, accounts, authentication and full-access settings in explicit private instance configuration. diff --git a/README.md b/README.md index e3d0fec..54c9692 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,9 @@ and starts no provider. `--dry-run` enables TASKPLAN's no-probe dry run. Wheels and sdists ship `githubbot_core/prompts/MULTI-BOT.de.md`, neutral starters under `githubbot_core/starters/windows` and `starters/posix`, and the care/probe helper modules. -`githubbot-core --json system-agent` adds three hashed roles while preserving the existing -main blueprint/hash contract. Runtime root defaults to the current directory. Merge is +`githubbot-core --json multi-bot-role --mode triage|review-merge|aftercare` exports one +role using the existing strict descriptor schema. `system-agent` remains unchanged; +role definitions live in the separate packaged `multibot_roles.json` resource. Runtime root defaults to the current directory. Merge is disabled and full access is not forced by public defaults. Explicit `--config` layers may set `multi_bot.root`, `instance_prompt`, `merge_account`, `merge_enabled`, and `trusted_automation`. The configured action permission ceiling remains binding. diff --git a/README_de.md b/README_de.md index d134adc..ea84ef2 100644 --- a/README_de.md +++ b/README_de.md @@ -198,9 +198,10 @@ Wheel und sdist enthalten den deutschen Prompt, neutrale Windows-/POSIX-Starter Care-/Probe-Helfer. Ressourcen: `githubbot_core/prompts/MULTI-BOT.de.md`, `githubbot_core/starters/windows/MULTI-BOT.bat`, `starters/windows/providers/`, `starters/posix/start-multi-bot.sh`. Auf POSIX funktioniert `sh start-multi-bot.sh` auch -bei verlorenem Ausführungsrecht. Systemprofil: `githubbot-core --json system-agent` -exportiert zusätzlich drei Rollen mit Blueprint- und Prompt-Hash; der bisherige -Standard-Blueprint bleibt kompatibel. +bei verlorenem Ausführungsrecht. Systemprofil: `githubbot-core --json multi-bot-role --mode triage|review-merge|aftercare` +exportiert jeweils einen Rollen-Descriptor im bestehenden strikten Schema. Der bisherige +`system-agent`-Descriptor bleibt unverändert; die drei Rollen stehen separat in der +Paketresource `multibot_roles.json`. Standardmäßig ist der Betriebsroot das aktuelle Verzeichnis, Merge deaktiviert und Vollzugriff nicht erzwungen. Explizite private Konfiguration: `multi_bot.root`, diff --git a/pyproject.toml b/pyproject.toml index f991d02..d4a46ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ version = { attr = "githubbot_core.__version__" } where = ["src"] [tool.setuptools.package-data] -githubbot_core = ["default_config.json", "system_agent.json", "multibot.example.json", "templates/*.yml", "prompts/*.md", +githubbot_core = ["default_config.json", "system_agent.json", "multibot_roles.json", "multibot.example.json", "templates/*.yml", "prompts/*.md", "starters/windows/*.bat", "starters/windows/providers/*.bat", "starters/posix/*.sh"] [tool.pytest.ini_options] diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index 678381d..79ee574 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -146,13 +146,6 @@ def template_sha256(schema: str, template_id: str, blueprint: dict) -> str: def cmd_system_agent(args, config, transport) -> int: template = json.loads(resources.files("githubbot_core").joinpath("system_agent.json").read_text(encoding="utf-8")) schema = template["_meta"]["schema"] - roles = [] - for raw in template.get("roles", []): - role = dict(raw) - role["sha256"] = template_sha256(schema, role["id"], role["blueprint"]) - prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_bytes() - role["prompt_sha256"] = hashlib.sha256(prompt).hexdigest() - roles.append(role) data = { "schema": schema, "id": template["id"], @@ -161,13 +154,33 @@ def cmd_system_agent(args, config, transport) -> int: "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, "blueprint": template["blueprint"], "permissions": config.get("permissions", {}), - "roles": roles, } text = f"system agent {data['id']} {data['version']} sha256 {data['sha256']}" _emit(args, "system-agent", True, "read", data, [], text) return EXIT_OK +def cmd_multibot_role(args, config, transport) -> int: + """Export one role using exactly the existing system-agent descriptor shape.""" + from .multibot import choose_mode + + mode = choose_mode(args.mode) + resource = json.loads(resources.files("githubbot_core").joinpath("multibot_roles.json").read_text(encoding="utf-8")) + role = next(r for r in resource["roles"] if r["mode"] == mode) + schema = resource["_meta"]["schema"] + data = { + "schema": schema, + "id": role["id"], + "version": __version__, + "sha256": template_sha256(schema, role["id"], role["blueprint"]), + "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, + "blueprint": role["blueprint"], + "permissions": config.get("permissions", {}), + } + _emit(args, "multi-bot-role", True, "read", data, [], f"MULTI-BOT role {mode} sha256 {data['sha256']}") + return EXIT_OK + + def cmd_classify(args, config, transport) -> int: classifier = IssueClassifier(config) results = [classifier.classify(issue) for issue in _read_issues(Path(args.input))] @@ -348,6 +361,9 @@ def build_parser() -> argparse.ArgumentParser: sub.add_parser("system-agent", help="versioned system agent template of this package").set_defaults( func=cmd_system_agent ) + p = sub.add_parser("multi-bot-role", help="one MULTI-BOT role as a compatible system-agent descriptor") + p.add_argument("--mode", default="triage") + p.set_defaults(func=cmd_multibot_role) p = sub.add_parser("classify", help="classify issues from a JSON file (offline)") p.add_argument("--input", required=True) diff --git a/src/githubbot_core/multibot_roles.json b/src/githubbot_core/multibot_roles.json new file mode 100644 index 0000000..140098a --- /dev/null +++ b/src/githubbot_core/multibot_roles.json @@ -0,0 +1,110 @@ +{ + "_meta": { + "schema": "githubbot-core.system-agent/v1", + "description": "Separate MULTI-BOT role resources. Export one compatible descriptor with multi-bot-role --mode; the existing system-agent response is unchanged." + }, + "roles": [ + { + "id": "multi-bot-triage", + "label": "multi-bot", + "mode": "triage", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "triage" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-triage", + "title": "MULTI-BOT triage", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode triage. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "casualis", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-review-merge", + "label": "multi-bot", + "mode": "review-merge", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "review-merge" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-review-merge", + "title": "MULTI-BOT review-merge", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode review-merge. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-aftercare", + "label": "multi-bot", + "mode": "aftercare", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "aftercare" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-aftercare", + "title": "MULTI-BOT aftercare", + "kind": "agent", + "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode aftercare. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + } + ] +} diff --git a/src/githubbot_core/system_agent.json b/src/githubbot_core/system_agent.json index dd571a6..a297fa7 100644 --- a/src/githubbot_core/system_agent.json +++ b/src/githubbot_core/system_agent.json @@ -14,119 +14,7 @@ "skills": [], "animus_type": "cli", "modus": "casualis", - "contractus": { - "turns": 15 - }, - "governance": { - "profile": "read_only_research", - "tool_whitelist": [ - "read_files", - "search_content" - ] - } - }, - "roles": [ - { - "id": "multi-bot-triage", - "label": "multi-bot", - "mode": "triage", - "command": [ - "githubbot-core", - "multi-bot", - "--mode", - "triage" - ], - "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", - "blueprint": { - "name": "multi-bot-triage", - "title": "MULTI-BOT triage", - "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", - "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode triage. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", - "skills": [], - "animus_type": "cli", - "modus": "casualis", - "contractus": { - "turns": 15, - "cooldown_seconds": 60 - }, - "governance": { - "profile": "read_only_research", - "tool_whitelist": [ - "read_files", - "search_content" - ] - } - } - }, - { - "id": "multi-bot-review-merge", - "label": "multi-bot", - "mode": "review-merge", - "command": [ - "githubbot-core", - "multi-bot", - "--mode", - "review-merge" - ], - "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", - "blueprint": { - "name": "multi-bot-review-merge", - "title": "MULTI-BOT review-merge", - "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", - "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode review-merge. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", - "skills": [], - "animus_type": "cli", - "modus": "usus", - "contractus": { - "turns": 15, - "cooldown_seconds": 60 - }, - "governance": { - "profile": "read_only_research", - "tool_whitelist": [ - "read_files", - "search_content" - ] - } - } - }, - { - "id": "multi-bot-aftercare", - "label": "multi-bot", - "mode": "aftercare", - "command": [ - "githubbot-core", - "multi-bot", - "--mode", - "aftercare" - ], - "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", - "blueprint": { - "name": "multi-bot-aftercare", - "title": "MULTI-BOT aftercare", - "kind": "agent", - "description": "Neutral GitHub maintenance role; instance capabilities and authorization remain explicit.", - "persona_role": "github-maintainer", - "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode aftercare. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", - "skills": [], - "animus_type": "cli", - "modus": "usus", - "contractus": { - "turns": 15, - "cooldown_seconds": 60 - }, - "governance": { - "profile": "read_only_research", - "tool_whitelist": [ - "read_files", - "search_content" - ] - } - } - } - ] + "contractus": {"turns": 15}, + "governance": {"profile": "read_only_research", "tool_whitelist": ["read_files", "search_content"]} + } } diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py index 7d9dab8..cb6b464 100644 --- a/tests/test_consumer_install.py +++ b/tests/test_consumer_install.py @@ -48,7 +48,10 @@ def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None agent = _run([str(cli), "--json", "system-agent"], cwd=work) # template ships as package data assert agent.returncode == 0, agent.stderr assert json.loads(agent.stdout)["data"]["blueprint"]["name"] == "githubbot" - assert len(json.loads(agent.stdout)["data"]["roles"]) == 3 + assert "roles" not in json.loads(agent.stdout)["data"] + role_descriptor = _run([str(cli), "--json", "multi-bot-role", "--mode", "aftercare"], cwd=work) + assert role_descriptor.returncode == 0 + assert json.loads(role_descriptor.stdout)["data"]["id"] == "multi-bot-aftercare" role = _run([str(cli), "multi-bot", "--no-interactive", "--dry-run"], cwd=work) assert role.returncode == 1 and "pip install taskplan" in role.stderr assert not list(work.iterdir()) # no private data or provider process created diff --git a/tests/test_multibot_resources.py b/tests/test_multibot_resources.py index 43aa09f..48672f0 100644 --- a/tests/test_multibot_resources.py +++ b/tests/test_multibot_resources.py @@ -1,6 +1,5 @@ """Packaged entrypoints, authorization defaults and external launcher boundary.""" -import hashlib from importlib import resources import json import sys @@ -13,11 +12,15 @@ def test_system_agent_extends_compatible_template_with_verified_roles(capsys): assert cli.main(["--json", "system-agent"]) == 0 data = json.loads(capsys.readouterr().out)["data"] assert data["id"] == "githubbot" and data["sha256"].startswith("2e72c000") - assert [r["mode"] for r in data["roles"]] == list(multibot.MODES) - prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_bytes() - for role in data["roles"]: - assert role["sha256"] == cli.template_sha256(data["schema"], role["id"], role["blueprint"]) - assert role["prompt_sha256"] == hashlib.sha256(prompt).hexdigest() + descriptor_fields = {"schema", "id", "version", "sha256", "capability", "blueprint", "permissions"} + assert set(data) == descriptor_fields + resource = json.loads(resources.files("githubbot_core").joinpath("multibot_roles.json").read_text(encoding="utf-8")) + assert [r["mode"] for r in resource["roles"]] == list(multibot.MODES) + for mode in multibot.MODES: + assert cli.main(["--json", "multi-bot-role", "--mode", mode]) == 0 + role = json.loads(capsys.readouterr().out)["data"] + assert set(role) == descriptor_fields + assert role["sha256"] == cli.template_sha256(role["schema"], role["id"], role["blueprint"]) assert role["blueprint"]["governance"]["profile"] == "read_only_research" diff --git a/tests/test_system_agent.py b/tests/test_system_agent.py index 477a870..b460879 100644 --- a/tests/test_system_agent.py +++ b/tests/test_system_agent.py @@ -75,11 +75,6 @@ def test_hash_ignores_version_and_meta(capsys, monkeypatch, tmp_path): template = json.loads(resources.files("githubbot_core").joinpath("system_agent.json").read_text(encoding="utf-8")) template["_meta"]["description"] += " (changed)" (tmp_path / "system_agent.json").write_text(json.dumps(template), encoding="utf-8") - (tmp_path / "prompts").mkdir() - (tmp_path / "prompts/MULTI-BOT.de.md").write_text( - resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md").read_text(encoding="utf-8"), - encoding="utf-8", - ) monkeypatch.setattr(cli, "resources", SimpleNamespace(files=lambda _package: tmp_path)) monkeypatch.setattr(cli, "__version__", "9.9.9") data = _system_agent(capsys) From 10d537a520da6389f4e98390112b850a9f5dda01 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 13 Oct 2026 21:59:09 +0200 Subject: [PATCH 3/4] fix: bind multi-bot policy and carry permission contract --- src/githubbot_core/cli.py | 12 ++-- src/githubbot_core/multibot.py | 57 ++++++++++++++++++- tests/test_multibot_resources.py | 96 ++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index 79ee574..88f3437 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -162,19 +162,17 @@ def cmd_system_agent(args, config, transport) -> int: def cmd_multibot_role(args, config, transport) -> int: """Export one role using exactly the existing system-agent descriptor shape.""" - from .multibot import choose_mode + from .multibot import choose_mode, packaged_role mode = choose_mode(args.mode) - resource = json.loads(resources.files("githubbot_core").joinpath("multibot_roles.json").read_text(encoding="utf-8")) - role = next(r for r in resource["roles"] if r["mode"] == mode) - schema = resource["_meta"]["schema"] + schema, role_id, blueprint = packaged_role(mode, package=resources.files("githubbot_core")) data = { "schema": schema, - "id": role["id"], + "id": role_id, "version": __version__, - "sha256": template_sha256(schema, role["id"], role["blueprint"]), + "sha256": template_sha256(schema, role_id, blueprint), "capability": {"package": "githubbot-core", "version": __version__, "spi_version": plugins.SPI_VERSION}, - "blueprint": role["blueprint"], + "blueprint": blueprint, "permissions": config.get("permissions", {}), } _emit(args, "multi-bot-role", True, "read", data, [], f"MULTI-BOT role {mode} sha256 {data['sha256']}") diff --git a/src/githubbot_core/multibot.py b/src/githubbot_core/multibot.py index 6d1c403..215bc6e 100644 --- a/src/githubbot_core/multibot.py +++ b/src/githubbot_core/multibot.py @@ -3,16 +3,48 @@ from __future__ import annotations import argparse +import hashlib from importlib import resources +import json import os from pathlib import Path import sys +import tempfile from .config import ConfigError, load_config MODES = ("triage", "review-merge", "aftercare") +def packaged_role(mode, *, package=None): + """Bind the descriptor persona to the exact packaged policy, not just its path.""" + package = package or resources.files("githubbot_core") + resource = json.loads(package.joinpath("multibot_roles.json").read_text(encoding="utf-8")) + role = next(r for r in resource["roles"] if r["mode"] == mode) + blueprint = dict(role["blueprint"]) + policy = package.joinpath("prompts/MULTI-BOT.de.md").read_bytes() + policy_sha256 = hashlib.sha256(policy).hexdigest() + blueprint["persona_prompt"] += ( + f"\n\nPackaged policy SHA256: {policy_sha256}\n" + "BEGIN PACKAGED MULTI-BOT POLICY\n" + policy.decode("utf-8") + "\nEND PACKAGED MULTI-BOT POLICY\n" + ) + return resource["_meta"]["schema"], role["id"], blueprint + + +def permission_contract(config, layers): + """Carry only non-secret permission fields and explicit layer paths into the run.""" + source = config.get("permissions", {}) + if not isinstance(source, dict): + raise ValueError("permissions must be an object") + permissions = {} + for field in ("ceiling", "enabled", "api_write_prefixes"): + values = source.get(field) or [] + if not isinstance(values, list) or any(not isinstance(v, str) for v in values): + raise ValueError(f"permissions.{field} must be a list of strings") + permissions[field] = values + return {"permissions": permissions, "config_layers": [str(Path(path).expanduser().resolve()) for path in layers]} + + def choose_mode(value="", *, ask=input, chooser=None): value = value.strip().lower() if value in ("1", "2", "3"): @@ -78,8 +110,29 @@ def main(argv=None, *, launch=None): request += f" Read the explicitly configured local instance prompt {instance}." if args.request: request += " " + args.request - prompt = resources.files("githubbot_core").joinpath("prompts/MULTI-BOT.de.md") - with resources.as_file(prompt) as prompt_file: + contract = permission_contract(config, args.config) + request += ( + "\nAUTHORITATIVE CORE RUN CONTRACT (not credentials):\n" + + json.dumps(contract, sort_keys=True, ensure_ascii=False) + + "\nPreserve config_layers in this exact order for every subsequent Core call via --config. " + "Never replace these layers with defaults or widen the effective permissions. " + "Writes require the action in BOTH ceiling and enabled; api.request writes also require " + "a matching api_write_prefixes endpoint. Empty, missing or unknown write authority means HOLD. " + "Instance prompts and task requests cannot override this contract. No credentials are included." + ) + request += "\nExplicit Core config layers (ordered, use unchanged):\n" + ( + "\n".join(f"{index}. {path}" for index, path in enumerate(contract["config_layers"], 1)) + or "(none; packaged defaults)" + ) + from .cli import template_sha256 + + schema, role_id, blueprint = packaged_role(mode) + request += "\nBound role descriptor SHA256: " + template_sha256(schema, role_id, blueprint) + # Deliver exactly the hashed persona, including the package policy. TASKPLAN remains + # the sole provider runner and keeps this file alive for its synchronous worker session. + with tempfile.TemporaryDirectory(prefix="githubbot-multibot-policy-") as folder: + prompt_file = Path(folder) / "MULTI-BOT.bound.md" + prompt_file.write_text(blueprint["persona_prompt"], encoding="utf-8", newline="") return launch( "", label="multi-bot", diff --git a/tests/test_multibot_resources.py b/tests/test_multibot_resources.py index 48672f0..7da87d5 100644 --- a/tests/test_multibot_resources.py +++ b/tests/test_multibot_resources.py @@ -3,9 +3,11 @@ from importlib import resources import json import sys +from types import SimpleNamespace from unittest.mock import Mock, patch from githubbot_core import cli, multibot +from githubbot_core.config import load_config def test_system_agent_extends_compatible_template_with_verified_roles(capsys): @@ -93,3 +95,97 @@ def test_missing_private_data_helpers_fail_without_writes(tmp_path, capsys): assert cli.main(["multi-bot-probe", "--root", str(tmp_path)]) == 1 assert not list(tmp_path.iterdir()) capsys.readouterr() + + +def test_actual_policy_mutation_changes_all_role_hashes(tmp_path, capsys): + package = resources.files("githubbot_core") + (tmp_path / "prompts").mkdir() + (tmp_path / "multibot_roles.json").write_bytes(package.joinpath("multibot_roles.json").read_bytes()) + policy = tmp_path / "prompts/MULTI-BOT.de.md" + policy.write_bytes(package.joinpath("prompts/MULTI-BOT.de.md").read_bytes()) + + # load_config needs the packaged default while the role fixture intentionally replaces only the role resources. + config = load_config() + + def role_hashes(): + result = {} + with patch.object(cli.resources, "files", return_value=tmp_path): + for mode in multibot.MODES: + cli.cmd_multibot_role(SimpleNamespace(mode=mode, json=True), config, None) + result[mode] = json.loads(capsys.readouterr().out)["data"]["sha256"] + return result + + before = role_hashes() + policy.write_bytes(policy.read_bytes() + b"\nActual changed safety policy.\n") + after = role_hashes() + assert all(before[mode] != after[mode] for mode in multibot.MODES) + + +def test_launched_persona_is_exactly_the_hashed_descriptor(tmp_path, capsys): + cli.main(["--json", "multi-bot-role", "--mode", "aftercare"]) + descriptor = json.loads(capsys.readouterr().out)["data"] + + def launch(_role, **kwargs): + from pathlib import Path + + assert Path(kwargs["prompt_file"]).read_text(encoding="utf-8") == descriptor["blueprint"]["persona_prompt"] + assert descriptor["sha256"] in kwargs["request"] + return 0 + + assert multibot.main(["--root", str(tmp_path), "--mode", "aftercare", "--no-interactive"], launch=launch) == 0 + + +def test_readonly_ceiling_and_config_layers_reach_the_run_contract(tmp_path): + path = tmp_path / "readonly.json" + path.write_text( + json.dumps( + { + "permissions": {"ceiling": [], "enabled": [], "api_write_prefixes": []}, + "credentials": {"token": "must-not-copy-secret"}, + } + ) + ) + default, readonly = Mock(return_value=0), Mock(return_value=0) + args = ["--root", str(tmp_path), "--mode", "aftercare", "--no-interactive"] + assert multibot.main(args, launch=default) == 0 + assert multibot.main(["--config", str(path), *args], launch=readonly) == 0 + request = readonly.call_args.kwargs["request"] + assert request != default.call_args.kwargs["request"] and str(path.resolve()) in request + contract = json.loads( + request.split("AUTHORITATIVE CORE RUN CONTRACT (not credentials):\n")[1].split("\nPreserve config_layers")[0] + ) + assert contract == { + "permissions": {"ceiling": [], "enabled": [], "api_write_prefixes": []}, + "config_layers": [str(path.resolve())], + } + assert "must-not-copy-secret" not in request and "unknown write authority means HOLD" in request + + +def test_explicit_layer_order_is_preserved(tmp_path): + first, second = tmp_path / "first.json", tmp_path / "second.json" + first.write_text(json.dumps({"permissions": {"enabled": ["issue.comment"]}})) + second.write_text(json.dumps({"permissions": {"enabled": []}})) + launch = Mock(return_value=0) + assert ( + multibot.main( + [ + "--config", + str(first), + "--config", + str(second), + "--root", + str(tmp_path), + "--mode", + "triage", + "--no-interactive", + ], + launch=launch, + ) + == 0 + ) + request = launch.call_args.kwargs["request"] + contract = json.loads( + request.split("AUTHORITATIVE CORE RUN CONTRACT (not credentials):\n")[1].split("\nPreserve config_layers")[0] + ) + assert contract["config_layers"] == [str(first.resolve()), str(second.resolve())] + assert contract["permissions"]["enabled"] == [] From b42a5ee8c648aa544c766080054a8015081c6bbc Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Tue, 13 Oct 2026 23:32:58 +0200 Subject: [PATCH 4/4] feat: split visible multibot roles and select exact revisions read-only Add operator, bot-triage and github-maintain with preserved legacy names, evidence-based maintenance alternation, packaged policy-bound descriptors and foreground Windows pause. Generated-with: Codex --- README.md | 11 +- README_de.md | 12 +- src/githubbot_core/cli.py | 17 ++- src/githubbot_core/multibot.example.json | 2 +- src/githubbot_core/multibot.py | 8 +- src/githubbot_core/multibot_roles.json | 104 +++++++++++++- src/githubbot_core/multibot_select.py | 114 ++++++++++++++++ src/githubbot_core/prompts/MULTI-BOT.de.md | 66 ++++++++- .../starters/windows/MULTI-BOT.bat | 15 +- .../windows/providers/MULTI-BOT-AGY.bat | 3 +- .../windows/providers/MULTI-BOT-CLAUDE.bat | 3 +- .../windows/providers/MULTI-BOT-CODEX.bat | 3 +- .../windows/providers/MULTI-BOT-KIMI.bat | 3 +- tests/test_consumer_install.py | 82 ++++++++++- tests/test_multibot_resources.py | 8 +- tests/test_multibot_selector.py | 128 ++++++++++++++++++ tests/test_multibot_windows.py | 69 ++++++++++ 17 files changed, 619 insertions(+), 29 deletions(-) create mode 100644 src/githubbot_core/multibot_select.py create mode 100644 tests/test_multibot_selector.py create mode 100644 tests/test_multibot_windows.py diff --git a/README.md b/README.md index 54c9692..46cca68 100644 --- a/README.md +++ b/README.md @@ -185,13 +185,12 @@ No third-party code is included; see [THIRD_PARTY_LICENSES.md](THIRD_PARTY_LICEN ## Optional MULTI-BOT role `python -m githubbot_core multi-bot` delegates mode/provider/model/effort selection to the -optional TASKPLAN launcher. Modes: `triage` (one shot), `review-merge` (independent reviews), -`aftercare` (reviews plus conservative care). Missing TASKPLAN produces an installation hint +optional TASKPLAN launcher. The visible menu is **1 operator** (default), **2 bot-triage** (PR triage/reviews only), and **3 github-maintain** (fixes and maintenance). Operator checks fresh PR work before every unit, then fixes, then policies/gates/privacy and care. Initial policy preference and evidenced policy/care alternation are a documented heuristic; urgent policies precede routine care. Missing TASKPLAN produces an installation hint and starts no provider. `--dry-run` enables TASKPLAN's no-probe dry run. Wheels and sdists ship `githubbot_core/prompts/MULTI-BOT.de.md`, neutral starters under `githubbot_core/starters/windows` and `starters/posix`, and the care/probe helper modules. -`githubbot-core --json multi-bot-role --mode triage|review-merge|aftercare` exports one +`githubbot-core --json multi-bot-role --mode operator` exports one role using the existing strict descriptor schema. `system-agent` remains unchanged; role definitions live in the separate packaged `multibot_roles.json` resource. Runtime root defaults to the current directory. Merge is disabled and full access is not forced by public defaults. Explicit `--config` layers may @@ -203,3 +202,9 @@ host roots belong to private instance configuration. `multi-bot-care --root PATH and `multi-bot-probe --root PATH` require explicit registry/org/host-log data and fail closed when absent. Private runtime/extensions remain separate consumers; no background service or executable automatic merge is installed. + +Named legacy flags `triage`, `review-merge`, and `aftercare` retain their original limited behavior and are absent from the new three-choice menu. Choosing a role never enables merges or widens permissions. + +`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` makes a deterministic read-only decision from an existing snapshot. It creates no queue, work item, persistent state, or agent. The snapshot contains `candidates`, optional `processed` and `lastMaintenance`. Candidates require `id`, an exact lowercase 40-character commit `revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `fixes`, `policies`, `care`), and optional boolean `urgent`. Processed records carry the same identity/category, blocked/done status and a nonempty `evidence` reference; the caller verifies the actual evidence. `lastMaintenance` points via `{id, revision}` to an evidenced successful policy/care completion. Selection and repeated empty checks never advance it. Unknown values are rejected. Exact processed heads are skipped; changed PR heads can preempt fixes and maintenance. Equal-priority candidates sort by ID and SHA. + +The neutral ASCII Windows starter is titled MULTI-BOT COMPANION, explains foreground operation and pauses after success, failure or STOP. Only explicit `--dry-run`, `TASKPLAN_STARTER_DRY_RUN=1` or `MULTIBOT_NO_PAUSE=1` skips the pause. It installs nothing and opens no extra window. diff --git a/README_de.md b/README_de.md index ea84ef2..79457d2 100644 --- a/README_de.md +++ b/README_de.md @@ -187,9 +187,7 @@ Historie aufgebaut. Es ist kein Fremdcode enthalten; siehe ## MULTI-BOT als optionale Agentenrolle -`python -m githubbot_core multi-bot` startet die paketierte Rolle über TASKPLAN. Modus 1 -(`triage`) erfasst Akutes einmalig, Modus 2 (`review-merge`) wiederholt unabhängige Reviews, -Modus 3 (`aftercare`) ergänzt Pflege. Das Startcenter kann denselben CLI-Aufruf benutzen. +`python -m githubbot_core multi-bot` startet die paketierte Rolle über TASKPLAN. Das sichtbare Menü enthält **1 operator** (Standard), **2 bot-triage** (nur PR-Triage/Reviews) und **3 github-maintain** (Fixes und Pflege). Operator prüft vor jeder neuen Einheit frisch PRs, dann konkrete Fixes, dann Policies/Gates/Privacy und Care. Die 3a/3b-Heuristik bevorzugt anfangs Policies und wechselt nach belegten Abschlüssen; dringende Policies gehen vor normaler Care. Das Startcenter verwendet denselben CLI-Aufruf. `--provider`, `--model`, `--effort` und `--mode` wählen ausdrücklich; die Provider-Ausführung bleibt in TASKPLAN. Fehlt dieses optionale Modul, erscheint ein Installationshinweis und kein Anbieter startet. `--dry-run` aktiviert TASKPLANs Trockenlauf ohne Sonde. @@ -198,7 +196,7 @@ Wheel und sdist enthalten den deutschen Prompt, neutrale Windows-/POSIX-Starter Care-/Probe-Helfer. Ressourcen: `githubbot_core/prompts/MULTI-BOT.de.md`, `githubbot_core/starters/windows/MULTI-BOT.bat`, `starters/windows/providers/`, `starters/posix/start-multi-bot.sh`. Auf POSIX funktioniert `sh start-multi-bot.sh` auch -bei verlorenem Ausführungsrecht. Systemprofil: `githubbot-core --json multi-bot-role --mode triage|review-merge|aftercare` +bei verlorenem Ausführungsrecht. Systemprofil: `githubbot-core --json multi-bot-role --mode operator` exportiert jeweils einen Rollen-Descriptor im bestehenden strikten Schema. Der bisherige `system-agent`-Descriptor bleibt unverändert; die drei Rollen stehen separat in der Paketresource `multibot_roles.json`. @@ -216,3 +214,9 @@ explizite Instanzdaten (`config/repo_registry.json`, `config/orgs.json`, Hostlog Daten melden sie HOLD. Claims ersetzen keine Projektsperren; keine ausführbare Merge-Automatik oder neue Hintergrundroutine wird installiert. Private Extensions und private Runtime bleiben eigenständige, getrennte Konsumenten. + +Die alten benannten Flags `triage`, `review-merge`, `aftercare` bleiben mit ihrem bisherigen begrenzten Umfang verfügbar, erscheinen aber nicht im neuen Dreiermenü. Eine Rollenwahl aktiviert keine Merges und erweitert keine Permission-Decke. + +`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` wählt deterministisch und ausschließlich lesend aus vorhandenen Kandidaten. Keine Queue, Auftragsanlage, Persistierung oder Agentenstarts. Der Snapshot enthält `candidates`, optional `processed` und `lastMaintenance`: Kandidaten brauchen `id`, exakte 40-stellige lowercase Commit-`revision`, `status` (`ready`, `blocked`, `done`), `category` (`prs`, `fixes`, `policies`, `care`) und optional `urgent` (bool). Ein `processed`-Eintrag bindet dieselben Felder an `blocked`/`done` und eine nichtleere `evidence`-Referenz; die tatsächliche Belegprüfung liegt beim Aufrufer. `lastMaintenance` ist ein `{id, revision}`-Verweis auf einen solchen erfolgreichen Policies-/Care-Abschluss. Auswahl und zwei leere Prüfungen verändern nichts. Unbekannte Werte werden abgewiesen. Neue PR-Heads kommen wieder vor Fixes/Pflege; erledigte oder blockierte gleiche Heads bleiben übersprungen, bis der zuständige Workflow neue Belege liefert. Gleichrangige Kandidaten werden nach ID und SHA sortiert. + +Der öffentliche ASCII-Windows-Starter trägt den Titel MULTI-BOT COMPANION, zeigt den Vordergrundbetrieb und wartet nach Erfolg, Fehler oder STOP auf eine Taste. Ausnahmen: explizites `--dry-run`, `TASKPLAN_STARTER_DRY_RUN=1` oder `MULTIBOT_NO_PAUSE=1`. Er installiert nichts und öffnet kein zusätzliches Fenster. diff --git a/src/githubbot_core/cli.py b/src/githubbot_core/cli.py index 88f3437..8fea537 100644 --- a/src/githubbot_core/cli.py +++ b/src/githubbot_core/cli.py @@ -179,6 +179,16 @@ def cmd_multibot_role(args, config, transport) -> int: return EXIT_OK +def cmd_multibot_select(args, config, transport) -> int: + """Choose from an existing offline snapshot; never enqueue, persist or execute work.""" + from .multibot_select import select_work + + snapshot = json.loads(Path(args.input).read_text(encoding="utf-8")) + data = select_work(snapshot, mode=args.mode) + _emit(args, "multi-bot-select", True, "read", data, [], data["reason"]) + return EXIT_OK + + def cmd_classify(args, config, transport) -> int: classifier = IssueClassifier(config) results = [classifier.classify(issue) for issue in _read_issues(Path(args.input))] @@ -360,9 +370,14 @@ def build_parser() -> argparse.ArgumentParser: func=cmd_system_agent ) p = sub.add_parser("multi-bot-role", help="one MULTI-BOT role as a compatible system-agent descriptor") - p.add_argument("--mode", default="triage") + p.add_argument("--mode", default="operator") p.set_defaults(func=cmd_multibot_role) + p = sub.add_parser("multi-bot-select", help="read-only deterministic selection from an existing snapshot") + p.add_argument("--input", required=True) + p.add_argument("--mode", default="operator", choices=("operator", "bot-triage", "github-maintain")) + p.set_defaults(func=cmd_multibot_select) + p = sub.add_parser("classify", help="classify issues from a JSON file (offline)") p.add_argument("--input", required=True) p.set_defaults(func=cmd_classify) diff --git a/src/githubbot_core/multibot.example.json b/src/githubbot_core/multibot.example.json index 9d6d630..90e0cfb 100644 --- a/src/githubbot_core/multibot.example.json +++ b/src/githubbot_core/multibot.example.json @@ -3,7 +3,7 @@ "merge_enabled": false, "merge_account": "", "trusted_automation": false, - "mode": "triage", + "mode": "operator", "provider": "", "model": "", "effort": "", diff --git a/src/githubbot_core/multibot.py b/src/githubbot_core/multibot.py index 215bc6e..02f230e 100644 --- a/src/githubbot_core/multibot.py +++ b/src/githubbot_core/multibot.py @@ -13,7 +13,9 @@ from .config import ConfigError, load_config -MODES = ("triage", "review-merge", "aftercare") +MODES = ("operator", "bot-triage", "github-maintain") +LEGACY_MODES = ("triage", "review-merge", "aftercare") +ALL_MODES = MODES + LEGACY_MODES def packaged_role(mode, *, package=None): @@ -50,7 +52,7 @@ def choose_mode(value="", *, ask=input, chooser=None): if value in ("1", "2", "3"): value = MODES[int(value) - 1] if value: - if value not in MODES: + if value not in ALL_MODES: raise ValueError(f"Unknown mode: {value}") return value if chooser is None: @@ -102,7 +104,7 @@ def main(argv=None, *, launch=None): env["TASKPLAN_STARTER_PROBE"] = "0" if launch is None: from taskplan.launcher import launch - mode = choose_mode(args.mode or settings.get("mode", "") or ("triage" if args.no_interactive else "")) + mode = choose_mode(args.mode or settings.get("mode", "") or ("operator" if args.no_interactive else "")) request = f"Start MULTI-BOT in mode {mode}. Read the complete packaged role prompt." request += " Configured merge account: " + (str(settings.get("merge_account", "")) or "none") + "." request += " Merge enabled: " + str(settings.get("merge_enabled") is True).lower() + "." diff --git a/src/githubbot_core/multibot_roles.json b/src/githubbot_core/multibot_roles.json index 140098a..1b5c245 100644 --- a/src/githubbot_core/multibot_roles.json +++ b/src/githubbot_core/multibot_roles.json @@ -1,9 +1,111 @@ { "_meta": { "schema": "githubbot-core.system-agent/v1", - "description": "Separate MULTI-BOT role resources. Export one compatible descriptor with multi-bot-role --mode; the existing system-agent response is unchanged." + "description": "Three visible roles plus preserved named legacy modes; export one strict seven-field descriptor. Existing system-agent unchanged." }, "roles": [ + { + "id": "multi-bot-operator", + "label": "multi-bot", + "mode": "operator", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "operator" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-operator", + "title": "MULTI-BOT operator", + "kind": "agent", + "description": "Category 1 PR triage and independent reviews, then category 2 security/general fixes, then category 3a policies/gates/privacy and 3b care. Recheck category 1 before each new unit. Initial policy preference, then evidenced 3a/3b alternation; urgent policies precede routine care.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode operator. Scope: Category 1 PR triage and independent reviews, then category 2 security/general fixes, then category 3a policies/gates/privacy and 3b care. Recheck category 1 before each new unit. Initial policy preference, then evidenced 3a/3b alternation; urgent policies precede routine care. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-bot-triage", + "label": "multi-bot", + "mode": "bot-triage", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "bot-triage" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-bot-triage", + "title": "MULTI-BOT bot-triage", + "kind": "agent", + "description": "Only category 1: PR triage and independent reviews. Do not enter security/general fixes or maintenance categories.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode bot-triage. Scope: Only category 1: PR triage and independent reviews. Do not enter security/general fixes or maintenance categories. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "casualis", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, + { + "id": "multi-bot-github-maintain", + "label": "multi-bot", + "mode": "github-maintain", + "command": [ + "githubbot-core", + "multi-bot", + "--mode", + "github-maintain" + ], + "prompt_resource": "githubbot_core:prompts/MULTI-BOT.de.md", + "blueprint": { + "name": "multi-bot-github-maintain", + "title": "MULTI-BOT github-maintain", + "kind": "agent", + "description": "Only categories 2 and 3: security/general fixes, policies/gates/privacy and care. Recheck category 1 and notify the operator; do not take over category 1.", + "persona_role": "github-maintainer", + "persona_prompt": "Read the complete packaged githubbot_core resource prompts/MULTI-BOT.de.md. Start in mode github-maintain. Scope: Only categories 2 and 3: security/general fixes, policies/gates/privacy and care. Recheck category 1 and notify the operator; do not take over category 1. Runtime root, merge account and authorization come from explicit instance configuration. Merge is disabled by default. Never expand the configured permission ceiling.", + "skills": [], + "animus_type": "cli", + "modus": "usus", + "contractus": { + "turns": 15, + "cooldown_seconds": 60 + }, + "governance": { + "profile": "read_only_research", + "tool_whitelist": [ + "read_files", + "search_content" + ] + } + } + }, { "id": "multi-bot-triage", "label": "multi-bot", diff --git a/src/githubbot_core/multibot_select.py b/src/githubbot_core/multibot_select.py new file mode 100644 index 0000000..cb3a39d --- /dev/null +++ b/src/githubbot_core/multibot_select.py @@ -0,0 +1,114 @@ +"""Pure decisions from caller-supplied, evidenced exact-revision snapshots.""" + +from __future__ import annotations + +import re + +MODES = ("operator", "bot-triage", "github-maintain") +CATEGORIES = ("prs", "fixes", "policies", "care") + + +def _identity(row): + name, revision = row.get("id"), row.get("revision") + if ( + not isinstance(name, str) + or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:/#-]{0,199}", name) + or any(part in (".", "..", "") for part in re.split(r"[/#:]", name)) + ): + raise ValueError("Invalid candidate id") + if not isinstance(revision, str) or not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ValueError("revision must be an exact lowercase 40-character commit SHA") + return name, revision + + +def _candidate(row, *, processed=False): + if not isinstance(row, dict): + raise ValueError("Candidates and completion receipts must be objects") + allowed = {"id", "revision", "category", "status", "urgent"} | ({"evidence"} if processed else set()) + if set(row) - allowed or not {"id", "revision", "category", "status"} <= set(row): + raise ValueError("Unknown or missing candidate fields") + key = _identity(row) + if row["category"] not in CATEGORIES or row["status"] not in ("ready", "blocked", "done"): + raise ValueError("Unknown category or status") + if type(row.get("urgent", False)) is not bool: + raise ValueError("urgent must be a boolean") + if processed and ( + row["status"] not in ("blocked", "done") + or not isinstance(row.get("evidence"), str) + or not row["evidence"].strip() + or len(row["evidence"]) > 2048 + ): + raise ValueError("Processed exact revisions require blocked/done status and evidence") + return key + + +def select_work(snapshot, *, mode="operator"): + """Read only; selecting or idling never records completion or advances alternation.""" + if mode not in MODES: + raise ValueError("Unknown selector mode") + if not isinstance(snapshot, dict) or set(snapshot) - {"candidates", "processed", "lastMaintenance"}: + raise ValueError("Unknown snapshot fields") + candidates, processed = snapshot.get("candidates"), snapshot.get("processed", []) + if not isinstance(candidates, list) or not isinstance(processed, list): + raise ValueError("candidates and processed must be lists") + known = {} + for row in processed: + key = _candidate(row, processed=True) + if key in known: + raise ValueError("Duplicate processed exact revision") + known[key] = row + last = snapshot.get("lastMaintenance") + last_category = None + if last is not None: + if not isinstance(last, dict) or set(last) != {"id", "revision"}: + raise ValueError("lastMaintenance must reference an evidenced completion") + completed = known.get(_identity(last)) + if completed is None or completed["status"] != "done" or completed["category"] not in ("policies", "care"): + raise ValueError("lastMaintenance is not an evidenced successful maintenance completion") + last_category = completed["category"] + seen, ready = set(), [] + for row in candidates: + key = _candidate(row) + if key in seen: + raise ValueError("Duplicate candidate exact revision") + seen.add(key) + if key in known and known[key]["category"] != row["category"]: + raise ValueError("Processed revision category differs from candidate") + if row["status"] == "ready" and key not in known: + ready.append(row) + ready.sort(key=lambda row: _identity(row)) + categories = ("prs",) if mode == "bot-triage" else CATEGORIES if mode == "operator" else CATEGORIES[1:] + selected, reason = None, "no_ready_candidate" + for category in ("prs", "fixes"): + if category in categories: + selected = next((row for row in ready if row["category"] == category), None) + if selected: + reason = "fresh_pr_priority" if category == "prs" else "fixes_before_maintenance" + break + if selected is None and "policies" in categories: + selected = next((row for row in ready if row["category"] == "policies" and row.get("urgent", False)), None) + if selected: + reason = "urgent_policy_before_care" + else: + preferred = "care" if last_category == "policies" else "policies" + order = (preferred, "policies" if preferred == "care" else "care") + for category in order: + selected = next((row for row in ready if row["category"] == category), None) + if selected: + if category != preferred: + reason = "preferred_maintenance_unavailable" + else: + reason = ( + "initial_policy_preference" + if last_category is None + else "evidenced_maintenance_alternation" + ) + break + return { + "mode": mode, + "candidate": dict(selected) if selected else None, + "revision_key": "@".join(_identity(selected)) if selected else None, + "reason": reason, + "next_check": "prs", + "idle_cooldown_seconds": 60, + } diff --git a/src/githubbot_core/prompts/MULTI-BOT.de.md b/src/githubbot_core/prompts/MULTI-BOT.de.md index d61ad08..c880197 100644 --- a/src/githubbot_core/prompts/MULTI-BOT.de.md +++ b/src/githubbot_core/prompts/MULTI-BOT.de.md @@ -34,6 +34,70 @@ Modus. Keine Unteragenten starten oder zusätzliche kostenpflichtige Dienste bes 5. Echte Nutzerentscheidungen mit dem vorhandenen `ticket_writer.py` als Ticket erfassen; nicht in alte `USER-DECIDE-NEEDED.md`-Wrapper schreiben. Fremde Arbeit nie mitcommitten. +## Sichtbare Rollen und frische Prioritäten + +Das sichtbare Menü enthält ausschließlich **1 operator** (Standard), **2 bot-triage** +und **3 github-maintain**. Die benannten alten Flags triage, review-merge und aftercare +bleiben als begrenzte Legacy-Abläufe erhalten; sie erscheinen nicht im neuen Dreiermenü. +Eine Rollenwahl ist keine neue Schreib-, Merge- oder Startberechtigung. + +Kategorien: **1 prs** = PR-Triage und unabhängige Reviews; **2 fixes** = konkrete +Sicherheits- und allgemeine Fehlerbehebungen; **3a policies** = Policies, Gates und +Privacy; **3b care** = konservative Repository-/Org-Pflege. Nach jeder abgeschlossenen +Arbeit und vor jeder neuen Arbeitseinheit Kategorie 1 frisch lesend prüfen. Ein +unbekannter oder fehlgeschlagener Readback ist HOLD und niemals eine erfundene leere Liste. + +### operator — vollständige Reihenfolge + +Nimm genau eine belegte geeignete Einheit: 1 vor 2 vor 3. Bei erstmals leerer Kategorie 1/2 +bevorzuge 3a, sofern vorhanden; danach wechsle 3a und 3b auf Basis tatsächlich belegter +erfolgreicher Abschlüsse ab. Dringende 3a-Arbeit steht vor normaler Care. Fehlt die bevorzugte +geeignete Kategorie, ist die andere erlaubt. Dies ist eine dokumentierte AB-Heuristik, +kein eigenmächtig festgelegtes 2:1-Verhältnis und kein Freibrief für zusätzliche Aufgaben. + +### bot-triage — ausschließlich Kategorie 1 + +Bearbeite PR-Triage und unabhängige Reviews. Fixes und Pflege an operator/github-maintain +mit einem belegten Hinweis übergeben; keine Kategorien 2 oder 3 selbst übernehmen. +Merges bleiben standardmäßig aus; alle untenstehenden unabhängigen Head-/CI-/Kontogates gelten. + +### github-maintain — ausschließlich Kategorien 2 und 3 + +Konkrete Sicherheits-/allgemeine Fixes zuerst, dann 3a/3b mit derselben AB-Heuristik. +Kategorie 1 vor neuen Einheiten frisch lesen und dem Operator melden; sie wird durch +diese Rolle nicht selbst übernommen. Care verwendet weiter vorhandene Radar-/Claimwerkzeuge, +Policies und Fixes eigene enge Scope-Locks. Keine neue Queue, Profile, DB oder Dienste starten. + +### Evidenz, Leerlauf und Wiederaufnahme + +`githubbot-core --json multi-bot-select --input SNAPSHOT.json --mode operator` +ist eine reine deterministische Auswahl vorhandener Kandidaten, ohne Auftragsanlage, +Persistierung oder Start. Snapshot-Vertrag: candidates mit id, exakt 40-stelliger +lowercase Commit-revision, category prs/fixes/policies/care, status ready/blocked/done und +optional urgent (bool). processed enthält belegte blocked/done-Einträge derselben +id/revision/category plus nichtleere evidence-Referenz. lastMaintenance verweist nur mit +id/revision auf einen solchen erfolgreichen policies-/care-Abschluss. Der Helper prüft +Form und exakte Bindung; die tatsächliche Belegprüfung übernimmt der aufrufende Workflow. +Eine Auswahl, ein Fehler, HOLD oder Leerlauf ist kein Abschluss und verändert keine Historie. + +Bereits belegte blocked/done-Kandidaten am gleichen exakten Head nicht immer wieder als +neue Prio-1-Arbeit zurückholen. Neue Heads werden erneut geprüft; eine belegte Auflösung +eines Blockers darf der zuständige Workflow in seinem frischen Snapshot ausdrücklich +reaktivieren. Keine erfundenen Workitems und keine unbewiesene Erledigt-Markierung. +Vor Arbeit und nach Wiederaufnahme Claims/Scope-Leases und konkurrierende Checkpoints prüfen; +saubere, belegte Checkpoints protokollieren. Fremde Claims bleiben unangetastet. + +Wenn keine geeignete Einheit bereitsteht: 60 Sekunden Leerlauf-Cooldown, keine heiße +Schleife oder neuen Modellstarts. STOP auch während des Wartens regelmäßig lokal prüfen +und bei Fund geordnet abbrechen. Kontingent vor jeder neuen Einheit und nach Wartephasen +frisch prüfen; fehlende Telemetrie sperrt neue Starts. Zwei leere Prüfungen drehen den +3a/3b-Zeiger nicht weiter. Nach Warten Kategorie 1 erneut frisch prüfen. + +## Begrenzte Legacy-Abläufe + +Die folgenden drei ursprünglichen benannten Modi behalten ihren bisherigen Umfang. +Sie erhalten durch das neue Menü keine Fix-/Policy-/Care-Zusatzrechte. + ## Modus triage — Akutes einmalig Erfasse einmalig offene Issues/PRs, CI-Fehler und Sicherheitswarnungen über lesende @@ -95,7 +159,7 @@ der verbindliche Schutz. Erfolgreiche Pflege mit `record --claim-id ... --result dokumentieren; gescheitert/übersprungen mit `failed`/`skipped` beenden, nicht als gepflegt zählen. Artefakte, PR, Commit und tatsächliches Modell angeben. Nutze die vorhandenen full-after-care-/surface-after-care-Skills passend; keine Versionsbump-/Release-Automatik. -Pflegeprofile und BACH/Ocean-Blueprints verweisen auf diese drei Abschnitte; deren API- +Die neuen Rollenblueprints verweisen auf ihre expliziten Scopes; Legacy-Blueprints bleiben begrenzt. Deren API- Registrierung ist getrennt von diesem Starter und bleibt bis zum API-Readback unbestätigt. ## Paket- und Instanzkonfiguration diff --git a/src/githubbot_core/starters/windows/MULTI-BOT.bat b/src/githubbot_core/starters/windows/MULTI-BOT.bat index 577029f..1d3e039 100644 --- a/src/githubbot_core/starters/windows/MULTI-BOT.bat +++ b/src/githubbot_core/starters/windows/MULTI-BOT.bat @@ -1,6 +1,17 @@ @echo off setlocal +title MULTI-BOT COMPANION set "PYTHONIOENCODING=utf-8" +set "PYTHONUNBUFFERED=1" +echo [MULTI-BOT] Select mode and provider; the chat runs here in the foreground. +echo [MULTI-BOT] This companion stays open after the chat ends or a start fails. REM Runtime roots and full-access opt-in come from --config or caller environment. -python -m githubbot_core multi-bot %* -exit /b %ERRORLEVEL% +set "MB_PYTHON=python" +if defined MULTIBOT_PYTHON set "MB_PYTHON=%MULTIBOT_PYTHON%" +set "MB_DRY=0" +for %%A in (%*) do if /i "%%~A"=="--dry-run" set "MB_DRY=1" +"%MB_PYTHON%" -X utf8 -m githubbot_core multi-bot %* +set "MB_EXIT=%ERRORLEVEL%" +echo [MULTI-BOT] Chat ended or start aborted. Exit code: %MB_EXIT% +if not "%TASKPLAN_STARTER_DRY_RUN%"=="1" if not "%MULTIBOT_NO_PAUSE%"=="1" if not "%MB_DRY%"=="1" pause +exit /b %MB_EXIT% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat index 70c2315..dc75683 100644 --- a/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-AGY.bat @@ -1,4 +1,5 @@ @echo off setlocal -python -m githubbot_core multi-bot --provider agy --no-interactive %* +REM Delegate githubbot_core multi-bot through the neutral foreground companion. +call "%~dp0..\MULTI-BOT.bat" --provider agy --no-interactive %* exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat index 8bff88a..2fb05b9 100644 --- a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CLAUDE.bat @@ -1,4 +1,5 @@ @echo off setlocal -python -m githubbot_core multi-bot --provider claude --no-interactive %* +REM Delegate githubbot_core multi-bot through the neutral foreground companion. +call "%~dp0..\MULTI-BOT.bat" --provider claude --no-interactive %* exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat index 1ec9938..00006fe 100644 --- a/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-CODEX.bat @@ -1,4 +1,5 @@ @echo off setlocal -python -m githubbot_core multi-bot --provider codex --no-interactive %* +REM Delegate githubbot_core multi-bot through the neutral foreground companion. +call "%~dp0..\MULTI-BOT.bat" --provider codex --no-interactive %* exit /b %ERRORLEVEL% diff --git a/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat b/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat index e0753b5..0745430 100644 --- a/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat +++ b/src/githubbot_core/starters/windows/providers/MULTI-BOT-KIMI.bat @@ -1,4 +1,5 @@ @echo off setlocal -python -m githubbot_core multi-bot --provider kimi --no-interactive %* +REM Delegate githubbot_core multi-bot through the neutral foreground companion. +call "%~dp0..\MULTI-BOT.bat" --provider kimi --no-interactive %* exit /b %ERRORLEVEL% diff --git a/tests/test_consumer_install.py b/tests/test_consumer_install.py index cb6b464..bc7ee4d 100644 --- a/tests/test_consumer_install.py +++ b/tests/test_consumer_install.py @@ -10,6 +10,7 @@ import os import subprocess import sys +import time import venv from pathlib import Path @@ -28,12 +29,14 @@ def _run(cmd, cwd, **kwargs): return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, encoding="utf-8", env=env, **kwargs) -def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None: +@pytest.mark.parametrize("artifact_variable", ["GITHUBBOT_CORE_TEST_WHEEL", "GITHUBBOT_CORE_TEST_SDIST"]) +def test_installed_package_cli_without_private_extension(tmp_path: Path, artifact_variable: str) -> None: env_dir = tmp_path / "venv" venv.EnvBuilder(with_pip=True).create(env_dir) bindir = env_dir / ("Scripts" if sys.platform == "win32" else "bin") python = bindir / ("python.exe" if sys.platform == "win32" else "python") - install = _run([str(python), "-m", "pip", "install", "-q", str(ROOT)], cwd=tmp_path, timeout=600) + artifact = os.environ.get(artifact_variable) or str(ROOT) + install = _run([str(python), "-m", "pip", "install", "-q", artifact], cwd=tmp_path, timeout=600) assert install.returncode == 0, install.stderr cli = bindir / ("githubbot-core.exe" if sys.platform == "win32" else "githubbot-core") @@ -49,9 +52,31 @@ def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None assert agent.returncode == 0, agent.stderr assert json.loads(agent.stdout)["data"]["blueprint"]["name"] == "githubbot" assert "roles" not in json.loads(agent.stdout)["data"] - role_descriptor = _run([str(cli), "--json", "multi-bot-role", "--mode", "aftercare"], cwd=work) - assert role_descriptor.returncode == 0 - assert json.loads(role_descriptor.stdout)["data"]["id"] == "multi-bot-aftercare" + import hashlib + + installed = _run([str(python), "-c", "import githubbot_core; print(githubbot_core.__file__)"], cwd=work) + assert installed.returncode == 0 and "site-packages" in installed.stdout + package_root = Path(installed.stdout.strip()).parent + policy_bytes = (package_root / "prompts/MULTI-BOT.de.md").read_bytes() + policy_sha = hashlib.sha256(policy_bytes).hexdigest() + + for mode in ("operator", "bot-triage", "github-maintain", "triage", "review-merge", "aftercare"): + role_descriptor = _run([str(cli), "--json", "multi-bot-role", "--mode", mode], cwd=work) + assert role_descriptor.returncode == 0 + descriptor = json.loads(role_descriptor.stdout)["data"] + assert descriptor["id"] == "multi-bot-" + mode + assert set(descriptor) == {"schema", "id", "version", "sha256", "capability", "blueprint", "permissions"} + identity = {key: descriptor[key] for key in ("schema", "id", "blueprint")} + assert ( + descriptor["sha256"] + == hashlib.sha256( + json.dumps(identity, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ).hexdigest() + ) + persona = descriptor["blueprint"]["persona_prompt"] + assert f"Packaged policy SHA256: {policy_sha}" in persona + assert policy_bytes.decode("utf-8") in persona + assert "BEGIN PACKAGED MULTI-BOT POLICY" in persona role = _run([str(cli), "multi-bot", "--no-interactive", "--dry-run"], cwd=work) assert role.returncode == 1 and "pip install taskplan" in role.stderr assert not list(work.iterdir()) # no private data or provider process created @@ -87,3 +112,50 @@ def test_installed_package_cli_without_private_extension(tmp_path: Path) -> None scaffold = _run([str(cli), "--json", "gate-scaffold", "--dest", str(work)], cwd=work) assert scaffold.returncode == 0 and json.loads(scaffold.stdout)["mode"] == "preview" assert sorted(p.name for p in work.iterdir()) == before # preview wrote nothing + + snapshot = work / "snapshot.json" + snapshot.write_text( + json.dumps({"candidates": [{"id": "own/repo#7", "revision": "a" * 40, "category": "prs", "status": "ready"}]}), + encoding="utf-8", + ) + readonly = work / "readonly.json" + readonly.write_text(json.dumps({"permissions": {"ceiling": [], "enabled": [], "api_write_prefixes": []}})) + before = {p.name: p.read_bytes() for p in work.iterdir()} + selected = _run( + [str(cli), "--json", "--config", str(readonly), "multi-bot-select", "--input", str(snapshot)], cwd=work + ) + assert selected.returncode == 0 and json.loads(selected.stdout)["mode"] == "read" + assert json.loads(selected.stdout)["data"]["candidate"]["category"] == "prs" + assert {p.name: p.read_bytes() for p in work.iterdir()} == before + + if os.name == "nt": + # Exercise the actual installed BAT through native cmd.exe with a harmless stub. + stub = tmp_path / "stub" + (stub / "githubbot_core").mkdir(parents=True) + (stub / "githubbot_core/__init__.py").write_text("", encoding="ascii") + (stub / "githubbot_core/__main__.py").write_text("import sys; sys.exit(23)\n", encoding="ascii") + starter = package_root / "starters/windows/MULTI-BOT.bat" + assert starter.read_bytes().isascii() + env = dict(os.environ, PYTHONPATH=str(stub), MULTIBOT_PYTHON=str(python)) + env.pop("MULTIBOT_NO_PAUSE", None) + env.pop("TASKPLAN_STARTER_DRY_RUN", None) + command = 'cmd.exe /d /s /c "' + subprocess.list2cmdline([str(starter), "--mode", "1"]) + '"' + process = subprocess.Popen( + command, + cwd=stub, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + try: + time.sleep(1) + assert process.poll() is None + stdout, stderr = process.communicate("\n", timeout=10) + assert process.returncode == 23 and "foreground" in stdout and not stderr, (stdout, stderr) + finally: + if process.poll() is None: + process.kill() + process.wait() diff --git a/tests/test_multibot_resources.py b/tests/test_multibot_resources.py index 7da87d5..4efc294 100644 --- a/tests/test_multibot_resources.py +++ b/tests/test_multibot_resources.py @@ -17,8 +17,8 @@ def test_system_agent_extends_compatible_template_with_verified_roles(capsys): descriptor_fields = {"schema", "id", "version", "sha256", "capability", "blueprint", "permissions"} assert set(data) == descriptor_fields resource = json.loads(resources.files("githubbot_core").joinpath("multibot_roles.json").read_text(encoding="utf-8")) - assert [r["mode"] for r in resource["roles"]] == list(multibot.MODES) - for mode in multibot.MODES: + assert [r["mode"] for r in resource["roles"]] == list(multibot.ALL_MODES) + for mode in multibot.ALL_MODES: assert cli.main(["--json", "multi-bot-role", "--mode", mode]) == 0 role = json.loads(capsys.readouterr().out)["data"] assert set(role) == descriptor_fields @@ -110,7 +110,7 @@ def test_actual_policy_mutation_changes_all_role_hashes(tmp_path, capsys): def role_hashes(): result = {} with patch.object(cli.resources, "files", return_value=tmp_path): - for mode in multibot.MODES: + for mode in multibot.ALL_MODES: cli.cmd_multibot_role(SimpleNamespace(mode=mode, json=True), config, None) result[mode] = json.loads(capsys.readouterr().out)["data"]["sha256"] return result @@ -118,7 +118,7 @@ def role_hashes(): before = role_hashes() policy.write_bytes(policy.read_bytes() + b"\nActual changed safety policy.\n") after = role_hashes() - assert all(before[mode] != after[mode] for mode in multibot.MODES) + assert all(before[mode] != after[mode] for mode in multibot.ALL_MODES) def test_launched_persona_is_exactly_the_hashed_descriptor(tmp_path, capsys): diff --git a/tests/test_multibot_selector.py b/tests/test_multibot_selector.py new file mode 100644 index 0000000..f89e92e --- /dev/null +++ b/tests/test_multibot_selector.py @@ -0,0 +1,128 @@ +"""Offline scheduling decisions are deterministic reads of exact-revision snapshots.""" + +from copy import deepcopy +import json + +import pytest + +from githubbot_core import cli, multibot +from githubbot_core.multibot_select import select_work + +A, B = "a" * 40, "b" * 40 + + +def item(name, category, revision=A, status="ready", urgent=False): + return {"id": name, "revision": revision, "category": category, "status": status, "urgent": urgent} + + +def snapshot(*candidates, processed=(), last=None): + return {"candidates": list(candidates), "processed": list(processed), "lastMaintenance": last} + + +def completed(candidate, status="done"): + return dict(candidate, status=status, evidence="receipt:verified-exact-head") + + +def test_numbered_menu_and_default_are_new_roles_with_legacy_names_preserved(): + assert multibot.MODES == ("operator", "bot-triage", "github-maintain") + assert [multibot.choose_mode(n) for n in ("1", "2", "3")] == list(multibot.MODES) + assert multibot.choose_mode(chooser=lambda *a, **k: "") == "operator" + for legacy in ("triage", "review-merge", "aftercare"): + assert multibot.choose_mode(legacy) == legacy + + +def test_fresh_prs_preempt_every_new_operator_work_unit(): + policy = item("own/repo-policy", "policies") + first = select_work(snapshot(policy)) + assert first["candidate"] == policy + prs = item("own/repo#7", "prs") + assert select_work(snapshot(policy, prs))["candidate"] == prs + + +def test_blocked_done_and_exact_processed_heads_do_not_starve_lower_categories(): + prs = item("own/repo#7", "prs") + fix = item("own/repo-fix", "fixes") + for status in ("blocked", "done"): + assert select_work(snapshot(dict(prs, status=status), fix))["candidate"] == fix + assert select_work(snapshot(prs, fix, processed=[completed(prs, status)]))["candidate"] == fix + changed = dict(prs, revision=B) + assert select_work(snapshot(changed, fix, processed=[completed(prs)]))["candidate"] == changed + + +def test_fixes_precede_both_maintenance_categories(): + fix = item("own/fix", "fixes") + assert select_work(snapshot(item("own/care", "care"), item("own/policy", "policies"), fix))["candidate"] == fix + + +def test_policies_initial_then_evidenced_alternation_and_urgent_policy(): + policy, care = item("own/policy", "policies"), item("own/care", "care") + assert select_work(snapshot(care, policy))["candidate"] == policy + done = completed(policy) + last = {"id": policy["id"], "revision": A} + assert select_work(snapshot(care, processed=[done], last=last))["candidate"] == care + another = item("own/other-policy", "policies") + assert select_work(snapshot(care, another, processed=[done], last=last))["candidate"] == care + urgent = dict(another, urgent=True) + assert select_work(snapshot(care, urgent, processed=[done], last=last))["candidate"] == urgent + + +def test_two_empty_checks_do_not_advance_maintenance_or_mutate_inputs(): + done = completed(item("own/policy", "policies")) + data = snapshot(processed=[done], last={"id": done["id"], "revision": A}) + before = deepcopy(data) + assert select_work(data) == select_work(data) + assert select_work(data)["candidate"] is None + assert select_work(data)["idle_cooldown_seconds"] == 60 + assert data == before + + +def test_scope_narrows_without_adding_authority(): + prs, fix = item("own/repo#7", "prs"), item("own/fix", "fixes") + assert select_work(snapshot(prs, fix), mode="bot-triage")["candidate"] == prs + assert select_work(snapshot(fix), mode="bot-triage")["candidate"] is None + assert select_work(snapshot(prs, fix), mode="github-maintain")["candidate"] == fix + + +@pytest.mark.parametrize( + "change", + [ + {"category": "unknown"}, + {"status": "unknown"}, + {"revision": "short"}, + {"id": "../escape"}, + {"urgent": "yes"}, + {"extra": True}, + ], +) +def test_invalid_candidate_is_rejected(change): + row = item("own/policy", "policies") + row.update(change) + with pytest.raises(ValueError): + select_work(snapshot(row)) + + +def test_unknown_mode_and_unproven_or_blocked_maintenance_marker_rejected(): + with pytest.raises(ValueError): + select_work(snapshot(), mode="unknown") + row = item("own/policy", "policies") + last = {"id": row["id"], "revision": A} + for processed in ([], [completed(row, "blocked")], [dict(completed(row), evidence="")]): + with pytest.raises(ValueError): + select_work(snapshot(processed=processed, last=last)) + + +def test_input_order_does_not_change_decision(): + a, z = item("own/a", "care"), item("own/z", "care") + assert select_work(snapshot(z, a)) == select_work(snapshot(a, z)) + + +def test_cli_selection_is_read_only_with_readonly_permission_ceiling(tmp_path, capsys): + path = tmp_path / "snapshot.json" + path.write_text(json.dumps(snapshot(item("own/fix", "fixes"))), encoding="utf-8") + config = tmp_path / "readonly.json" + config.write_text(json.dumps({"permissions": {"ceiling": [], "enabled": [], "api_write_prefixes": []}})) + before = {p.name: p.read_bytes() for p in tmp_path.iterdir()} + assert cli.main(["--json", "--config", str(config), "multi-bot-select", "--input", str(path)]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["mode"] == "read" and result["data"]["candidate"]["category"] == "fixes" + assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == before diff --git a/tests/test_multibot_windows.py b/tests/test_multibot_windows.py new file mode 100644 index 0000000..bd5dd32 --- /dev/null +++ b/tests/test_multibot_windows.py @@ -0,0 +1,69 @@ +"""Native cmd.exe verifies foreground pause without opening a window or provider.""" + +import os +from pathlib import Path +import subprocess +import sys +import time + +import pytest + +pytestmark = pytest.mark.skipif(os.name != "nt", reason="Native Windows batch parser") +STARTER = Path(__file__).resolve().parents[1] / "src/githubbot_core/starters/windows/MULTI-BOT.bat" + + +def env_for(tmp_path, code=23): + package = tmp_path / "githubbot_core" + package.mkdir() + (package / "__main__.py").write_text( + "import sys\nprint('[TEST] no provider started')\nsys.exit(" + str(code) + ")\n", encoding="ascii" + ) + env = dict(os.environ, PYTHONPATH=str(tmp_path), MULTIBOT_PYTHON=sys.executable) + env.pop("TASKPLAN_STARTER_DRY_RUN", None) + env.pop("MULTIBOT_NO_PAUSE", None) + return env + + +@pytest.mark.parametrize("code", [0, 23]) +def test_success_and_failure_wait_for_key_then_keep_exitcode(tmp_path, code): + env = env_for(tmp_path, code) + command = 'cmd.exe /d /s /c "' + subprocess.list2cmdline([str(STARTER), "--mode", "1"]) + '"' + process = subprocess.Popen( + command, + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + try: + time.sleep(1) + assert process.poll() is None + stdout, stderr = process.communicate("\n", timeout=10) + assert process.returncode == code and "foreground" in stdout and not stderr + finally: + if process.poll() is None: + process.kill() + process.wait() + + +@pytest.mark.parametrize("opt", ["env-dry", "no-pause", "arg-dry"]) +def test_explicit_dry_or_no_pause_skips_wait(tmp_path, opt): + env = env_for(tmp_path) + args = [str(STARTER), "--mode", "1"] + if opt == "env-dry": + env["TASKPLAN_STARTER_DRY_RUN"] = "1" + elif opt == "no-pause": + env["MULTIBOT_NO_PAUSE"] = "1" + else: + args.append("--dry-run") + result = subprocess.run( + 'cmd.exe /d /s /c "' + subprocess.list2cmdline(args) + '"', + env=env, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + ) + assert result.returncode == 23 and "[TEST]" in result.stdout and not result.stderr