#!/usr/bin/env bash set -euo pipefail repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd) agents_dir="$repo_root/agents" catalog="$agents_dir/catalog.json" loader="$agents_dir/AGENTS.md" orga="$agents_dir/orga.md" admin="$agents_dir/admin.md" agent_manager="$agents_dir/agent-manager.md" graphics="$agents_dir/graphics.md" reviewer="$agents_dir/reviewer.md" builder="$agents_dir/builder.md" planner="$agents_dir/planner.md" researcher="$agents_dir/researcher.md" operator="$agents_dir/operator.md" fixer="$agents_dir/fixer.md" inspector="$agents_dir/inspector.md" settings_dir="$agents_dir/claude-settings" orchestration="$repo_root/docs/herdr-orchestration.md" self_close_design="$agents_dir/design/herdr-self-close-entwurf.md" conventions="$agents_dir/conventions.md" graphify_skill="$repo_root/.agents/skills/graphify/SKILL.md" graphify_version="$repo_root/.agents/skills/graphify/.graphify_version" tick='`' fail() { printf 'FAIL: %s\n' "$1" >&2 exit 1 } assert_contains() { local file=$1 local text=$2 grep -Fq -- "$text" "$file" || fail "$file enthält nicht: $text" } assert_not_contains() { local file=$1 local text=$2 if grep -Fq -- "$text" "$file"; then fail "$file enthält unerlaubt: $text" fi } assert_text_contains() { local label=$1 local content=$2 local text=$3 [[ $content == *"$text"* ]] || fail "$label enthält nicht: $text" } assert_text_order() { local label=$1 local content=$2 local before=$3 local after=$4 local remainder assert_text_contains "$label" "$content" "$before" assert_text_contains "$label" "$content" "$after" remainder=${content#*"$before"} [[ $remainder == *"$after"* ]] \ || fail "$label: erwartete Reihenfolge fehlt: $before vor $after" } role_header_value() { local file=$1 local key=$2 sed -n "2,/^---$/s/^${key}: //p" "$file" } assert_role_header_matches_catalog() { local role_id=$1 local definition=$2 local expected_version=$3 local role_file="$agents_dir/$definition" local catalog_definition catalog_status catalog_version [[ $(sed -n '1p' "$role_file") == --- ]] \ || fail "$definition: Rollenheader fehlt" catalog_definition=$(jq -r --arg id "$role_id" \ '.roles[] | select(.id == $id) | .definition' "$catalog") catalog_status=$(jq -r --arg id "$role_id" \ '.roles[] | select(.id == $id) | .status' "$catalog") catalog_version=$(jq -r --arg id "$role_id" \ '.roles[] | select(.id == $id) | .version' "$catalog") [[ $catalog_definition == "$definition" ]] \ || fail "$role_id: Definitionsdatei weicht vom Katalog ab" [[ $catalog_version == "$expected_version" ]] \ || fail "$role_id: erwartete Katalogversion $expected_version" [[ $(role_header_value "$role_file" role-id) == "$role_id" ]] \ || fail "$definition: role-id weicht vom Katalog ab" [[ $(role_header_value "$role_file" role-version) == "$catalog_version" ]] \ || fail "$definition: role-version weicht vom Katalog ab" [[ $(role_header_value "$role_file" status) == "$catalog_status" ]] \ || fail "$definition: status weicht vom Katalog ab" } jq -e '.schema_version == 1' "$catalog" >/dev/null \ || fail "unbekannte Katalogversion" jq -e '.profile_policy.sol_high_requires_measured_quality == true and .profile_policy.defaults_are_configurable == true and .profile_policy.running_processes_are_unchanged == true' "$catalog" \ >/dev/null || fail "Modellprofil-Richtlinie ist nicht sicher konfiguriert" duplicate_ids=$(jq -r '[.roles[].id] | length == (unique | length)' "$catalog") [[ $duplicate_ids == true ]] || fail "doppelte Rollen-ID im Katalog" profile_ids=$(jq -r '[.model_profiles[].id] | length == (unique | length)' "$catalog") [[ $profile_ids == true ]] || fail "doppelte Modellprofil-ID im Katalog" assert_profile() { local id=$1 local model=$2 local effort=$3 local actual_model actual_effort actual_model=$(jq -r --arg id "$id" \ '.model_profiles[] | select(.id == $id) | (.runtimes.codex.model // "null")' "$catalog") actual_effort=$(jq -r --arg id "$id" \ '.model_profiles[] | select(.id == $id) | (.runtimes.codex.reasoning_effort // "null")' "$catalog") [[ $actual_model == "$model" ]] || fail "$id: unerwarteter Modelldefault" [[ $actual_effort == "$effort" ]] || fail "$id: unerwarteter Reasoning-Default" } assert_profile orchestrator gpt-5.6-terra medium assert_profile administrator gpt-5.6-terra high assert_profile agent-manager gpt-5.6-sol high assert_profile reviewer gpt-5.6-sol high assert_profile planner gpt-5.6-terra high assert_profile researcher gpt-5.6-terra medium assert_profile operator gpt-5.6-terra high assert_profile grafiker gpt-5.6-terra medium assert_profile orga gpt-5.6-terra medium assert_profile finance gpt-5.6-luna medium assert_profile dispatcher null null jq -e '.model_profiles[] | select(.id == "dispatcher") | .execution == "no-llm"' \ "$catalog" >/dev/null || fail "Dispatcher ist nicht als no-llm markiert" jq -e '.model_profiles[] | select(.id == "administrator") | .runtimes.codex.fallback.model == "gpt-5.6-sol" and .runtimes.codex.fallback.reasoning_effort == "high" and .runtimes.codex.fallback.condition == "nur bei gemessener Qualitätsnotwendigkeit"' \ "$catalog" >/dev/null || fail "Administrator-Eskalation ist falsch" jq -e '.model_profiles[] | select(.id == "researcher") | .runtimes.codex.fallback.model == "gpt-5.6-luna" and .runtimes.codex.fallback.reasoning_effort == "low" and .runtimes.codex.fallback.condition == "Routineinventur"' \ "$catalog" >/dev/null || fail "Researcher-Routineprofil ist falsch" jq -e '.model_profiles[] | select(.id == "grafiker") | .runtimes.codex.fallback.model == "gpt-5.6-luna" and .runtimes.codex.fallback.reasoning_effort == "low" and .runtimes.codex.fallback.condition == "reines Resize"' \ "$catalog" >/dev/null || fail "Grafik-Resizeprofil ist falsch" jq -e '.model_profiles[] | select(.id == "finance") | .legacy_fallbacks == "preserve-existing-historical-fallbacks"' \ "$catalog" >/dev/null || fail "historische Finance-Fallbacks nicht geschützt" while IFS= read -r profile_id; do [[ $profile_id == dispatcher ]] && continue jq -e --arg id "$profile_id" ' .model_profiles[] | select(.id == $id) | (.runtimes | has("codex") and has("claude")) and (.runtimes.codex.model | startswith("gpt-5.6-")) and (.runtimes.claude.model | startswith("claude-")) and (.runtimes.codex.reasoning_effort | IN("low", "medium", "high")) and (.runtimes.claude.reasoning_effort | IN("low", "medium", "high"))' \ "$catalog" >/dev/null || fail "$profile_id: unvollständige oder ungültige Runtime" done < <(jq -r '.model_profiles[].id' "$catalog") jq -e '.model_profiles[] | select(.id == "dispatcher") | (.runtimes | has("none")) and (.runtimes | has("codex") | not) and (.runtimes | has("claude") | not) and .runtime_policy == "no-llm; kein Codex- oder Claude-Prozess"' \ "$catalog" >/dev/null || fail "Dispatcher-Ausnahme ist nicht dokumentiert" jq -e '[.model_profiles[].runtimes.claude.fallback? // empty | select(.model != null and .reasoning_effort != null)] | length >= 5' "$catalog" \ >/dev/null || fail "Claude-Routinefallbacks fehlen" # Planner ist ab 4.0.0 die einzige, ausdrücklich dokumentierte Ausnahme von # defaultMode dontAsk (Pilotwechsel auf auto, um AskUserQuestion nutzbar zu # machen; siehe planner.md, Abschnitt "Direkter Dialog mit dem Nutzer"). while IFS=$'\t' read -r profile_id settings_file; do [[ -n $settings_file ]] || fail "$profile_id: Claude settings_file fehlt" [[ -f "$repo_root/$settings_file" ]] \ || fail "$profile_id: Claude settings_file fehlt auf dem Dateisystem" expected_default_mode=dontAsk [[ $profile_id == planner ]] && expected_default_mode=auto jq -e --arg mode "$expected_default_mode" '.permissions.defaultMode == $mode and (.permissions.allow | type == "array") and (.permissions.deny | type == "array")' "$repo_root/$settings_file" \ >/dev/null \ || fail "$profile_id: ungültige Claude-Settings-Struktur (defaultMode erwartet: $expected_default_mode)" while IFS= read -r grant; do case "$grant" in *nixos-rebuild\ switch*|*git\ push*|*agenix\ -d*|*sudo*) fail "$profile_id: gefährlicher Befehl als Claude-Allowlist-Grant" ;; esac done < <(jq -r '.permissions.allow[]' "$repo_root/$settings_file") assert_contains "$repo_root/$settings_file" "defaultMode" if grep -Fq -- ".claude/settings.local.json" "$repo_root/$settings_file"; then fail "$profile_id: persönliche settings.local.json referenziert" fi done < <(jq -r '.model_profiles[] | select(.runtimes.claude.settings_file?) | [.id, .runtimes.claude.settings_file] | @tsv' "$catalog") jq -e '.model_profiles[] | select(.id == "dispatcher") | (.runtimes.claude.settings_file? == null)' "$catalog" >/dev/null \ || fail "Dispatcher darf keine Claude-Settings voraussetzen" self_close_grant='Bash(/etc/scripts/close-my-pane.sh)' self_close_settings=( administrator.json agent-manager.json grafiker.json orga.json planner.json researcher.json ) self_close_roles=( "$admin" "$agent_manager" "$graphics" "$orga" "$researcher" ) assert_role_header_matches_catalog graphics graphics.md 2.0.0 assert_role_header_matches_catalog administrator admin.md 3.1.0 assert_role_header_matches_catalog agent-manager agent-manager.md 2.3.0 assert_role_header_matches_catalog orga orga.md 2.1.1 assert_role_header_matches_catalog planner planner.md 4.2.0 assert_role_header_matches_catalog researcher researcher.md 1.0.0 assert_role_header_matches_catalog operator operator.md 1.0.0 assert_role_header_matches_catalog fixer fixer.md 2.0.0 assert_role_header_matches_catalog inspector inspector.md 1.0.0 for settings_name in "${self_close_settings[@]}"; do settings_file="$settings_dir/$settings_name" grant_count=$(jq --arg grant "$self_close_grant" \ '[.permissions.allow[] | select(. == $grant)] | length' "$settings_file") [[ $grant_count -eq 1 ]] \ || fail "$settings_name: Self-Close-Wrapper muss exakt einmal erlaubt sein" close_grant_count=$(jq \ '[.permissions.allow[] | select(contains("close-my-pane"))] | length' \ "$settings_file") [[ $close_grant_count -eq 1 ]] \ || fail "$settings_name: nur der exakte Self-Close-Wrapper darf erlaubt sein" if [[ $settings_name != researcher.json ]]; then broad_bash_grants=$(jq \ '[.permissions.allow[] | select(. == "Bash(*)")] | length' \ "$settings_file") [[ $broad_bash_grants -eq 0 ]] \ || fail "$settings_name: Self-Close darf kein pauschales Bash-Recht erhalten" fi done # researcher.json ist die dokumentierte Ausnahme von der pauschalen # Bash(*)-Sperre oben (docs/intent/research-pipeline-researcher-shell.md, # Claude-Pfad, ausschließlich blunix): generischer Bash-Zugriff zur reinen # Informationsbeschaffung, abgesichert über eine Deny-Präfixliste statt # einer Befehls-Allowlist. Diese Deny-Liste bleibt hier verpflichtend. researcher_broad_bash_grants=$(jq \ '[.permissions.allow[] | select(. == "Bash(*)")] | length' \ "$settings_dir/researcher.json") [[ $researcher_broad_bash_grants -eq 1 ]] \ || fail "researcher.json: erwarteter generischer Bash-Zugriff fehlt" researcher_required_denies=( "Bash(sudo *)" "Bash(rm *)" "Bash(mv *)" "Bash(chmod *)" "Bash(chown *)" "Bash(kill *)" "Bash(systemctl start *)" "Bash(systemctl stop *)" "Bash(systemctl restart *)" "Bash(systemctl enable *)" "Bash(systemctl disable *)" "Bash(nixos-rebuild switch *)" "Bash(nixos-rebuild test *)" "Bash(reboot*)" "Bash(shutdown *)" "Bash(ssh *)" "Bash(agenix *)" ) for deny_entry in "${researcher_required_denies[@]}"; do deny_count=$(jq --arg deny "$deny_entry" \ '[.permissions.deny[] | select(. == $deny)] | length' \ "$settings_dir/researcher.json") [[ $deny_count -eq 1 ]] \ || fail "researcher.json: fehlender Deny-Eintrag $deny_entry für den generischen Bash-Zugriff" done for settings_file in "$settings_dir"/*.json; do settings_name=${settings_file##*/} # Seit 03.09.2026 (Nutzerentscheidung, siehe docs/herdr-orchestration.md, # Abschnitt "Bestätigungsfreier Spezialisten-Start durch den Orchestrator") # führt ausschließlich orchestrator.json ein breites Bash(herdr *)-Recht; # die vorher engeren Bash(herdr agent *)/Bash(herdr pane *)-Rechte sind # dadurch obsolet und für jede Settings-Datei unzulässig. narrow_herdr_grants=$(jq \ '[.permissions.allow[] | select(contains("herdr pane") or contains("herdr agent") or contains("herdr-pane-close"))] | length' \ "$settings_file") [[ $narrow_herdr_grants -eq 0 ]] \ || fail "$settings_name: enges Herdr-Agent-/Pane-Recht ist obsolet, nur Bash(herdr *) auf orchestrator.json ist vorgesehen" broad_herdr_grants=$(jq \ '[.permissions.allow[] | select(. == "Bash(herdr *)")] | length' \ "$settings_file") if [[ $settings_name == orchestrator.json ]]; then [[ $broad_herdr_grants -eq 1 ]] \ || fail "Orchestrator braucht genau sein breites Herdr-Recht (Bash(herdr *))" else [[ $broad_herdr_grants -eq 0 ]] \ || fail "$settings_name: freies Herdr-Recht (Bash(herdr *)) ist ausschließlich dem Orchestrator vorbehalten" fi wrapper_grants=$(jq --arg grant "$self_close_grant" \ '[.permissions.allow[] | select(. == $grant)] | length' "$settings_file") case " ${self_close_settings[*]} " in *" $settings_name "*) [[ $wrapper_grants -eq 1 ]] \ || fail "$settings_name: erwarteter Self-Close-Wrapper fehlt" ;; *) [[ $wrapper_grants -eq 0 ]] \ || fail "$settings_name: unerwartetes Self-Close-Recht" ;; esac done for role_file in "${self_close_roles[@]}"; do assert_contains "$role_file" 'Session-Abschluss (Self-Close)' assert_contains "$role_file" '/etc/scripts/close-my-pane.sh' assert_contains "$role_file" '$HERDR_PANE_ID' assert_contains "$role_file" 'ignoriert jedes Argument' assert_contains "$role_file" '**eigene**' assert_contains "$role_file" 'niemals eine fremde' assert_contains "$role_file" 'COMPLETE' assert_contains "$role_file" 'BLOCKED: …' assert_contains "$role_file" 'nachweislich gelesen' assert_contains "$role_file" 'ausdrücklich zum Schließen freigegeben' assert_contains "$role_file" 'bleibst idle' assert_contains "$role_file" 'Fehlt eine Bedingung' assert_contains "$role_file" '**Codex 0.145:**' assert_contains "$role_file" '`sandbox_permissions=require_escalated`' assert_contains "$role_file" '`workspace-write` wird blockiert' assert_contains "$role_file" 'Keine Argumente, kein' assert_contains "$role_file" '**Claude:**' assert_contains "$role_file" 'wegen des Sessionlimits noch aus' done # Nur den Repository-Vertrag prüfen. Externe Home- und System-Rules sind keine # Harness-Voraussetzung und werden hier weder gelesen noch auf Existenz geprüft. assert_contains "$orchestration" 'Testpane `w5:pQ`' assert_contains "$orchestration" '`sandbox_permissions=require_escalated`' assert_contains "$orchestration" '`~/.codex/rules/default.rules`' assert_contains "$orchestration" '`/etc/codex/rules`' assert_contains "$orchestration" 'symlinked User-Rules' assert_contains "$orchestration" 'Claude-End-to-End-Test bleibt wegen des Sessionlimits ungetestet' assert_contains "$self_close_design" 'rohen Herdr-Shell' assert_contains "$self_close_design" 'Testpane `w5:pQ`' assert_contains "$self_close_design" '`sandbox_permissions=require_escalated`' assert_contains "$self_close_design" 'setzt keine Datei im' assert_contains "$self_close_design" 'Home-Verzeichnis voraus' assert_not_contains "$reviewer" 'Session-Abschluss (Self-Close)' assert_not_contains "$reviewer" '/etc/scripts/close-my-pane.sh' jq -e --arg grant "$self_close_grant" \ '[.permissions.allow[] | select(. == $grant)] | length == 0' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer darf kein Self-Close-Recht erhalten" assert_catalog_role_version() { local role_id=$1 local expected_version=$2 local actual_version actual_version=$(jq -r --arg id "$role_id" \ '.roles[] | select(.id == $id) | .version' "$catalog") [[ $actual_version == "$expected_version" ]] \ || fail "$role_id: erwartete Rollenvertragsversion $expected_version" } # Self-Close öffnet eine neue Vertrauensgrenze und ist daher ein Major-Bump. assert_catalog_role_version graphics 2.0.0 # administrator 3.1.0: neuer Punkt im Ergebnisformat (Eskalationsanlass in # der Chat-Übergabe) - rückwärtskompatibles neues Ausgabeformat, Minor-Bump. assert_catalog_role_version administrator 3.1.0 # Eskalationsannahme (Fixer) bzw. Rückfrage-Kanal-Pilot sind rückwärts- # kompatible neue Aufgaben, daher Minor-Bump statt Major. 2.2.0: zusätzlicher # Ergebnisformat-Punkt (Eskalationsanlass), ebenfalls Minor. 2.3.0 bereinigt # dessen Anforderungsgrundlage wortsinngleich zum Rollenvertrag. assert_catalog_role_version agent-manager 2.3.0 assert_catalog_role_version orga 2.1.1 # reviewer 1.2.0: optionale Kennzahlen-Momentaufnahme und Eskalationsanlass # im Systemaudit-Bericht - neues, rückwärtskompatibles Ausgabeformat, Minor. # 1.3.0: nichtpersistierende Graphify-Übergabe für erlaubte read-only Queries; # die bestehende Save-/Reflect-Sperre bleibt erhalten, daher ebenfalls Minor. # 1.4.0 (docs/intent/git-intent-board-feedback.md, Abschnitt "Reviewer: keine # neue Aufgabenart"): Zusatzabsatz für den board-getriggerten Review - bleibt # dieselbe Diff-Review-Aufgabe mit festem Commit-Scope statt einer dritten # Aufgabenart, nutzt die bestehende docs/review/*.md-Schreibausnahme ohne # neue Rechte, headless-Start gilt ausschließlich für diesen Trigger. Kein # Trust-Boundary-Wechsel, daher Minor statt Major. # 1.5.0 (2026-09-07): Backend-Wechsel Codex -> Claude Code (`claude -p`) # für den board-getriggerten Review, ausgelöst durch dasselbe erschöpfte # Codex-Wochenkontingent, das schon am Vortag den Builder betroffen hat # (siehe assert_catalog_role_version builder oben). Effektive Rechte # unverändert (read-only gegenüber Code/Konfiguration, keine neue # Schreiberweiterung) -- Durchsetzung jetzt über `--disallowed-tools # Edit,Write,MultiEdit,NotebookEdit` plus reviewer.json-Deny-Liste statt # Codex' `--sandbox read-only`, daher Minor statt Major, analog zum # Builder-Präzedenzfall. # 1.6.0: Board-Jobs können Codex oder Claude als strikt validierten, # über Build und Review unveränderten Runtime-Snapshot tragen. Keine neue # Aufgabenart oder Schreiberweiterung, daher Minor. # 1.7.0: Prüftiefe und Berichtslänge skalieren am Diff-Risiko (riskant vs. # nicht-riskant) statt immer der vollen Struktur - der verpflichtende # git-grep-Mindestcheck bleibt in beiden Fällen bestehen, keine neue # Schreiberweiterung, daher Minor. # 1.8.0 (docs/intent/board-automatic-review-correction-loop.md): bei # "zurück zur Planung" liefert der Reviewer zusätzlich zum Freitext einen # maschinenlesbaren CORRECTION-ITEMS-Block, damit eine Board-Automatisierung # außerhalb dieses Rollenvertrags bis zu zwei automatische # Builder-Korrekturläufe anstoßen kann. Eigene Prüftiefe/-methode und die # Schreibgrenze auf docs/review/.md bleiben unverändert, daher Minor. assert_catalog_role_version reviewer 1.8.0 # builder 1.1.0 (docs/intent/builder-iterationslimit.md): atomare Sessions # statt Wiederaufnahme sowie ein konfigurierbares Iterationslimit (Start: 20) # zusätzlich zum unveränderten Wall-Clock-Timeout. Reine Verhaltensänderung - # keine neuen Schreibrechte, keine Sandbox-Änderung (workspace-write bleibt), # daher Minor statt Major. # 1.2.0 (2026-09-06): Backend-Wechsel Codex -> Claude Code (`claude -p`), # ausgelöst durch erschöpftes Codex-Wochenkontingent. Die effektiven Rechte # bleiben identisch (kein Commit/Push/Deploy/Flake-Check/Build, Schreibzugriff # nur in den bekannten Verzeichnissen) -- nur der Durchsetzungsmechanismus # wechselt von Codex' Kernel-Sandbox auf eine settings.json-Deny-Liste # (agents/claude-settings/builder.json), daher Minor statt Major. Katalogfeld # "sandbox" wechselt entsprechend von "workspace-write" auf "scoped-write". # 2.0.0 (2026-09-06): echter Vertrauensgrenzenwechsel, kein reines # Backend-Detail mehr -- der Builder darf jetzt nach einem # nixos-config-Commit selbst `nixos-rebuild switch` auf blunix ausführen # (deploy_blunix() in builder.py, wrapper-ausgeführt, nie ein # Bash-Aufruf des Modells selbst), ohne separate menschliche Freigabe # und ohne vorherige nix eval/build-Verifizierung. Bisher war "kein # Deploy, unter keinen Umständen" die durchgängig verankerte Grenze # (builder.md, Prompt, Settings-Deny-Liste) -- deren Aufhebung ist per # Definition Major, nicht Minor, auch wenn Claude selbst weiterhin # keinen sudo/nixos-rebuild-Zugriff bekommt. Bewusste # Nutzerentscheidung für die aktuelle Entwicklungsphase (Geschwindigkeit # vor Sicherheit, Rückbau über git revert/Generation-Wechsel), begrenzt # auf blunix lokal -- kein SSH, kein tnix/pinix. # 2.1.0: Runtime-Snapshot codex/claude für Board-Pipelines; Sicherheits- # und Deployment-Grenzen bleiben unverändert, daher Minor. assert_catalog_role_version builder 2.1.0 # Planner: direkter Nutzerdialog ersetzt Orchestrator-Relay und # Orchestrator-Schließfreigabe durch direkte Nutzerfreigabe im Pane - # geänderte Vertrauensgrenze, daher Major-Bump (3.0.0). 3.0.1 korrigiert nur # den wirkungslosen AskUserQuestion-Grant weg (dontAsk blockt das Tool # strukturell); reine Korrektur ohne Verhaltens- oder Grenzänderung = Patch. # 4.0.0: Pilotwechsel von dontAsk auf auto, ausschließlich für diese Rolle, # um AskUserQuestion nutzbar zu machen. Erneuter Major-Bump: auto ersetzt # die feste Deny-by-default-Garantie für ungelistete Aufrufe durch einen # Klassifikator - eine echte Vertrauensgrenzen-Verschiebung, kein reiner # Fähigkeitszugewinn (Einschätzung des Agent-Managers, abweichend vom im # Auftrag vorgeschlagenen Minor-Bump). # 4.1.0: bedingte Design-Zwischenstufe (Abgrenzungs-/Schnittstellenreview) # vor dem finalen Intent-Schreiben - rückwärtskompatibler neuer Dialog- # Schritt ohne neue Schreib-/Freigabegrenze, daher Minor-Bump. # 4.2.0: zusätzlicher Board-getriggerter Split-Modus (headless, read-only, # eigener fester Auftrag statt Nutzerdialog) - rein additiv, keine neue # Schreib-/Freigabegrenze, daher ebenfalls Minor-Bump. assert_catalog_role_version planner 4.2.0 assert_catalog_role_version researcher 1.0.0 assert_catalog_role_version operator 1.0.0 while IFS=$'\t' read -r role_id version definition start; do [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ || fail "$role_id hat keine semantische Version" [[ -f "$agents_dir/$definition" ]] || fail "$definition fehlt" # Der ausschließlich Board-headless gestartete Fixer wird durch Katalog und # expliziten Startauftrag geladen; agents/AGENTS.md liegt nicht im # exklusiven Dateibesitz dieses Auftrags. if [[ $role_id != fixer && $role_id != inspector ]]; then assert_contains "$loader" "\`$definition\`" fi done < <(jq -r '.roles[] | [.id, .version, .definition, .start] | @tsv' "$catalog") assert_contains "$loader" "nur ein neutraler Loader" assert_contains "$loader" "explizite Auftrag" # Fixer 2.0.0: brechend neuer FIX_RESULT-Vertrag und auf echte Defekte oder # negative Reviews verengte fachliche Startgrenze. Commit/Push gelten nur im # isolierten Worktree; Deployment bleibt ein Wrapper-/Runner-Schritt nach # positivem Review desselben Commits und ausschließlich auf blunix. assert_catalog_role_version fixer 2.0.0 fixer_catalog_start=$(jq -r '.roles[] | select(.id == "fixer") | .start' "$catalog") [[ $fixer_catalog_start == board-headless ]] \ || fail "Fixer ist nicht als board-headless katalogisiert" fixer_catalog_sandbox=$(jq -r '.roles[] | select(.id == "fixer") | .sandbox' "$catalog") [[ $fixer_catalog_sandbox == isolated-worktree-write ]] \ || fail "Fixer ist nicht auf einen isolierten Worktree begrenzt" assert_contains "$fixer" "echten Defekt oder einen negativen Review" assert_contains "$fixer" "isolierten Git-Worktree" assert_contains "$fixer" "committen" assert_contains "$fixer" "pushen" assert_contains "$fixer" "erfolgreichem Review" assert_contains "$fixer" "kontrollierte" assert_contains "$fixer" "blunix-Deploy-Runner" assert_contains "$fixer" 'niemals selbst `sudo`, `nixos-rebuild`' assert_contains "$fixer" "tnix und pinix sind" assert_contains "$fixer" "unvertrauenswürdige Daten" assert_contains "$fixer" "persistiert diese Angaben an der Karte" assert_contains "$fixer" "keine dauerhafte" assert_contains "$fixer" '===FIX_RESULT===' assert_contains "$fixer" '===END_FIX_RESULT===' for outcome in repaired needs_input not_applicable failed; do assert_contains "$fixer" "$outcome" done for field in schema_version outcome summary commit_hash pushed_branch tests \ question required_action review_required failure; do assert_contains "$fixer" "\"$field\"" done assert_not_contains "$fixer" 'Session-Abschluss (Self-Close)' assert_not_contains "$fixer" '/etc/scripts/close-my-pane.sh' # Inspector: zwei strikt getrennte, read-only Board-Modi. Der Wrapper liefert # und validiert allowed_actions und bewegt allein den Board-State. assert_catalog_role_version inspector 1.0.0 inspector_catalog_start=$(jq -r '.roles[] | select(.id == "inspector") | .start' "$catalog") [[ $inspector_catalog_start == board-headless ]] \ || fail "Inspector ist nicht als board-headless katalogisiert" inspector_catalog_sandbox=$(jq -r '.roles[] | select(.id == "inspector") | .sandbox' "$catalog") [[ $inspector_catalog_sandbox == read-only ]] \ || fail "Inspector ist nicht read-only" for text in implementation_status route_next present partial missing \ unverifiable deployed not_deployed unknown allowed_actions \ recommended_action '===INSPECTION_RESULT===' '===ROUTING_RESULT===' \ "persistiert" "neue atomare Inspector-Session" "unvertrauenswürdige Daten"; do assert_contains "$inspector" "$text" done assert_not_contains "$inspector" '"TARGET_PHASE"' assert_not_contains "$inspector" 'Session-Abschluss (Self-Close)' assert_not_contains "$inspector" '/etc/scripts/close-my-pane.sh' [[ -f "$graphify_skill" ]] || fail "projektlokaler Graphify-Skill fehlt" [[ -f "$graphify_version" ]] || fail "Graphify-Versionsstempel fehlt" [[ $(<"$graphify_version") =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ || fail "ungültiger Graphify-Versionsstempel" for reference in \ add-watch.md exports.md extraction-spec.md github-and-merge.md hooks.md \ query.md transcribe.md update.md; do [[ -f "$repo_root/.agents/skills/graphify/references/$reference" ]] \ || fail "Graphify-Referenz fehlt: $reference" done assert_contains "$graphify_skill" "name: graphify" assert_contains "$graphify_skill" 'spawn_agent' assert_contains "$graphify_skill" 'wait_agent' assert_contains "$graphify_skill" 'generic `Task` tool' assert_contains "$graphify_skill" "Do not call both interfaces for the same chunk" assert_contains "$graphify_skill" "This skill never expands the active role" assert_contains "$graphify_skill" "Never use Graphify-generated" assert_contains "$graphify_skill" "Role-aware query feedback" assert_contains "$graphify_skill" 'must not run `graphify save-result` or `graphify reflect`' assert_contains "$graphify_skill" "explicitly assigned write path for derived Graphify" assert_contains "$conventions" 'graphify install --project --platform agents' assert_contains "$conventions" 'graphify install --project --platform codex' assert_contains "$conventions" '`.agents/skills/graphify/`' assert_contains "$conventions" 'eigenen, exklusiven Dateibesitz' assert_contains "$conventions" 'Ein Skill-Aufruf erweitert niemals Netzwerk-, Schreib-, Deployment- oder' assert_contains "$conventions" 'Read-only Rollen dürfen dort erlaubte Query-/Path-/Explain-Aufrufe' assert_contains "$conventions" 'Dieses Feedback bleibt nichtpersistierend' assert_contains "$conventions" 'explizit zugewiesenen Schreibpfad' assert_contains "$conventions" '`graphify reflect` nach dem bestehenden Ablauf' if [[ ${1:-} == --graphify-only ]]; then printf 'OK: projektlokaler Graphify-Skill und Laufzeitgrenzen\n' exit 0 fi reviewer_catalog_status=$(jq -r '.roles[] | select(.id == "reviewer") | .status' "$catalog") [[ $reviewer_catalog_status == active ]] \ || fail "Reviewer ist nicht als aktiv katalogisiert" reviewer_catalog_sandbox=$(jq -r '.roles[] | select(.id == "reviewer") | .sandbox' "$catalog") [[ $reviewer_catalog_sandbox == scoped-write ]] \ || fail "Reviewer ist nicht als scoped-write katalogisiert" reviewer_catalog_version=$(jq -r '.roles[] | select(.id == "reviewer") | .version' "$catalog") [[ $reviewer_catalog_version == 1.8.0 ]] \ || fail "Reviewer-Version ist nicht 1.8.0" reviewer_catalog_start=$(jq -r '.roles[] | select(.id == "reviewer") | .start' "$catalog") [[ $reviewer_catalog_start == herdr-visible ]] \ || fail "Reviewer ist nicht als herdr-visible katalogisiert" reviewer_catalog_profile=$(jq -r '.roles[] | select(.id == "reviewer") | .profile_id' "$catalog") [[ $reviewer_catalog_profile == reviewer ]] \ || fail "Reviewer verweist nicht auf das reviewer-Modellprofil" reviewer_catalog_definition=$(jq -r '.roles[] | select(.id == "reviewer") | .definition' "$catalog") [[ $reviewer_catalog_definition == reviewer.md ]] \ || fail "Reviewer-Definitionsdatei ist falsch verknüpft" assert_contains "$loader" "${tick}reviewer.md${tick}" assert_contains "$agents_dir/reviewer.md" "role-id: reviewer" assert_contains "$agents_dir/reviewer.md" "role-version: 1.8.0" assert_contains "$agents_dir/reviewer.md" "ausschließlich lesend" assert_contains "$agents_dir/reviewer.md" "besitzt keine eigenen" assert_contains "$agents_dir/reviewer.md" "commitest, pushst und deployest nie" assert_contains "$agents_dir/reviewer.md" "Aktivierungs-, Deployment- oder Veröffentlichungsfreigabe" assert_contains "$agents_dir/reviewer.md" "unvertrauenswürdige Daten" assert_contains "$agents_dir/reviewer.md" "Volltext-Systemaudit" assert_contains "$agents_dir/reviewer.md" "docs/review/.md" assert_contains "$agents_dir/reviewer.md" "Token-Reduktionsregel" assert_contains "$agents_dir/reviewer.md" "offensichtliche" reviewer_system_audit=$(sed -n '/^## Aufgabenart: Volltext-Systemaudit/,/^## Arbeitsgrenzen und Sicherheit/p' "$reviewer" | tr '\n' ' ' | tr -s ' ') # Volltext-Systemaudit: genau eine Berichtsdatei am festgelegten Ort. assert_text_contains "Reviewer-Volltext-Systemaudit" "$reviewer_system_audit" \ 'Der Bericht wird bei `COMPLETE` als genau eine Datei' assert_text_contains "Reviewer-Volltext-Systemaudit" "$reviewer_system_audit" \ '`docs/review/.md` geschrieben' assert_text_contains "Reviewer-Volltext-Systemaudit" "$reviewer_system_audit" \ "dein einziges Datei-Artefakt" # Optionale Kennzahlen-Momentaufnahme im Systemaudit (SDLC-Playbook, Punkt 2): # grobe Blocker-/Eskalationsrate pro Rolle, keine laufende Pflegepflicht. assert_text_contains "Reviewer-Kennzahlen" "$reviewer_system_audit" \ "### Optionale Kennzahlen-Momentaufnahme (nur Volltext-Systemaudit)" assert_text_contains "Reviewer-Kennzahlen" "$reviewer_system_audit" \ "**darf** zusätzlich grob erfassen" assert_text_contains "Reviewer-Kennzahlen" "$reviewer_system_audit" \ "Blocker oder Eskalationen pro Rolle" assert_text_contains "Reviewer-Kennzahlen" "$reviewer_system_audit" \ "keine** laufende Pflegepflicht zwischen Audits" assert_text_contains "Reviewer-Kennzahlen" "$reviewer_system_audit" \ "statt Zahlen zu erfinden" # Eskalationsanlass dokumentieren (SDLC-Playbook, Punkt 3). assert_contains "$agents_dir/reviewer.md" "Eskalationsanlass" assert_contains "$agents_dir/reviewer.md" "nur bei" [[ -f "$repo_root/docs/review/.gitkeep" ]] \ || fail "Review-Berichtsverzeichnis fehlt" jq -e '[.permissions.allow[] | select(startswith("Edit("))] == ["Edit(docs/review/*.md)"]' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer darf nur Systemaudit-Berichte editieren" jq -e '[.permissions.deny[] | select(. == "Edit(**)" or . == "Edit(*)")] | length == 0' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer-Settings dürfen keinen pauschalen Edit-Deny enthalten" jq -e '[.permissions.allow[] + .permissions.deny[] | select(startswith("Write("))] | length == 0' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer-Settings dürfen keine Write-Regel enthalten" # Read-only Reviewer: Graphify-Abfragen bleiben erlaubt, persistierende # Feedback-Befehle bleiben technisch gesperrt und die Übergabe bleibt im Pane. jq -e '[.permissions.allow[] | select(. == "Bash(graphify query *)" or . == "Bash(graphify path *)" or . == "Bash(graphify explain *)")] | length == 3' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer braucht genau die drei read-only Graphify-Abfragen" jq -e '[.permissions.deny[] | select(. == "Bash(graphify save-result *)" or . == "Bash(graphify reflect *)")] | length == 2' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Reviewer muss save-result und reflect weiterhin sperren" reviewer_graphify_handoff=$(sed -n '/Graphify-Recherche/,/^## Review-Methode und Ergebnisformat/p' "$reviewer" | tr '\n' ' ' | tr -s ' ') for field in 'Query-Typ' 'useful' 'dead_end' 'corrected' 'zitierte Knoten/Quellen' 'die Korrektur' 'nichtpersistierend'; do assert_text_contains "Reviewer-Graphify-Übergabe" "$reviewer_graphify_handoff" "$field" done # Board-getriggerter Review (docs/intent/git-intent-board-feedback.md, # Abschnitt "Reviewer: keine neue Aufgabenart") - Zusatzabsatz zur bestehenden # freien Diff-Review, keine dritte Aufgabenart, headless-Ausnahme gilt nur # für diesen Trigger. reviewer_board_trigger=$(sed -n '/^## Board-getriggerter Review/,/^## Arbeitsgrenzen und Sicherheit/p' "$reviewer" | tr '\n' ' ' | tr -s ' ') for field in \ 'keine neue Aufgabenart' 'festen Commit-Scope' 'docs/review/.md' \ '`bereit für Deployment`' '`zurück zur Planung`' \ '**keine** automatische neue Planner-Session' \ 'headless statt herdr-visible' 'auf diesen Board-Trigger begrenzte Ausnahme'; do assert_text_contains "Reviewer-Board-Trigger" "$reviewer_board_trigger" "$field" done jq -e '[.permissions.allow[] | select(startswith("Edit("))] == ["Edit(docs/review/*.md)"]' \ "$settings_dir/reviewer.json" >/dev/null \ || fail "Board-getriggerter Review darf keine zusätzlichen Schreibrechte in reviewer.json bekommen" # Iterationslimit für atomare Builder-Aufträge # (docs/intent/builder-iterationslimit.md). builder_catalog_status=$(jq -r '.roles[] | select(.id == "builder") | .status' "$catalog") [[ $builder_catalog_status == active ]] || fail "Builder ist nicht als aktiv katalogisiert" # "scoped-write" seit 1.2.0 (Backend-Wechsel Codex -> Claude Code, # 2026-09-06, siehe assert_catalog_role_version builder oben) -- # vorher "workspace-write" (Codex-Sandbox-Begriff). Die effektiven Rechte # sind unverändert, nur der Durchsetzungsmechanismus (settings.json statt # Kernel-Sandbox) hat einen anderen Namen. builder_catalog_sandbox=$(jq -r '.roles[] | select(.id == "builder") | .sandbox' "$catalog") [[ $builder_catalog_sandbox == scoped-write ]] \ || fail "Builder-Sandbox-Feld stimmt nicht mit dem Claude-Backend-Wechsel überein" builder_catalog_start=$(jq -r '.roles[] | select(.id == "builder") | .start' "$catalog") [[ $builder_catalog_start == xmpp-worker ]] \ || fail "Builder-Startweise darf sich durch das Iterationslimit nicht ändern" builder_catalog_version=$(jq -r '.roles[] | select(.id == "builder") | .version' "$catalog") [[ $builder_catalog_version == 2.1.0 ]] || fail "Builder-Version ist nicht 2.1.0" builder_session_limit=$(sed -n '/^## Atomare Sessions und Iterationslimit/,/^## Direkter Dialog und Freigabe/p' "$builder" | tr '\n' ' ' | tr -s ' ') for field in \ 'neue, eigenständige Session' 'keine automatische Fortsetzung' \ '**20**' 'konfigurierbar' 'ohne neue Intent-Klärung' \ 'günstiges Profil' 'bekannten, konfigurierten Profilen' \ 'Iterationslimit gilt unabhängig' 'globale Obergrenze nie überschreiten' \ '**Kein** Prüf-Commit' '**kein** Reviewer-Lauf' '**kein** Push' '**kein** Deployment' \ 'Zwischenstandsbericht' 'als Kontext für die neue,'; do assert_text_contains "Builder-Iterationslimit" "$builder_session_limit" "$field" done assert_contains "$orga" "role-id: orga" orga_catalog_version=$( jq -r '.roles[] | select(.id == "orga") | .version' "$catalog" ) [[ $orga_catalog_version == 2.1.1 ]] \ || fail "unerwartete Orga-Version im Katalog" # Eskalationsannahme (Agent-Manager als Fixer für Rollenvertrags-Blocker) assert_contains "$agent_manager" "Eskalationsannahme (Fixer für Rollenvertrags-Blocker)" assert_contains "$agent_manager" "wörtlich, nicht paraphrasiert" assert_contains "$agent_manager" "bleiben Sache des" assert_contains "$agent_manager" "Administrators, nicht deine" # Eskalationsanlass im Ergebnisformat (SDLC-Playbook, Punkt 3): Anforderung # und Rollenvertrag sind wortsinngleich auf die Chat-Übergabe festgelegt. agent_manager_escalation=$(sed -n '/^## Ergebnisformat/,$p' "$agent_manager" | tr '\n' ' ' | tr -s ' ') sdlc_intent_escalation=$(sed -n '/^3\. \*\*Eskalationsanlass dokumentieren\*\*/,/^## Explizit ausgeschlossen/p' \ "$repo_root/docs/intent/sdlc-playbook-prozessergaenzungen.md" | tr '\n' ' ' | tr -s ' ') agent_manager_escalation_contract='Der Agent-Manager führt kein Datei-Artefakt; der Eskalationsanlass steht ausschließlich als zusätzlicher Punkt in der Chat-Übergabe an den Orchestrator.' assert_text_contains "Agent-Manager-Ergebnisformat" "$agent_manager_escalation" \ "$agent_manager_escalation_contract" assert_text_contains "SDLC-Intent" "$sdlc_intent_escalation" \ "$agent_manager_escalation_contract" assert_text_contains "Agent-Manager-Ergebnisformat" "$agent_manager_escalation" \ "gemessenem Qualitätsbedarf" agent_manager_catalog_version=$( jq -r '.roles[] | select(.id == "agent-manager") | .version' "$catalog" ) [[ $agent_manager_catalog_version == 2.3.0 ]] \ || fail "unerwartete Agent-Manager-Version im Katalog" # Rückfrage-Kanal-Pilot (ausschließlich Orga, promptbasierte Grenze) assert_contains "$orga" "Rückfrage-Kanal (Pilot)" assert_contains "$orga" "blockiert mit Rückfrage, Turn bleibt offen" assert_contains "$orga" "herdr pane report-agent" assert_contains "$orga" "state blocked" assert_contains "$orga" "keine technische" assert_contains "$orga" "als Capability-Handoff behandeln" assert_not_contains "$admin" "Rückfrage-Kanal (Pilot)" assert_not_contains "$reviewer" "Rückfrage-Kanal (Pilot)" jq -e '[.permissions.allow[] | select(. == "AskUserQuestion")] | length == 0' \ "$settings_dir/orga.json" >/dev/null \ || fail "Orga darf den Nutzer nicht direkt per AskUserQuestion befragen" assert_contains "$orchestration" "Reviewer-Einbindung, Eskalationskette und Rückfrage-Kanal" assert_contains "$orchestration" "Eigenständiger Volltext-Systemaudit" assert_contains "$orchestration" "docs/review/.md" assert_contains "$orchestration" "ausdrücklich ausgenommen" assert_contains "$orchestration" "Reviewer als Standardschritt ab Risikoschwelle" assert_contains "$orchestration" "ausschließlich für die Rolle \`orga\` freigegeben" assert_contains "$orchestration" "bewusst noch nicht gebaut" # Planner: eng pfadbeschränkte Discovery-Rolle, direkter Nutzerdialog im # eigenen Pane statt Orchestrator-Relay - seit 4.0.0 echter interaktiver # Dialog inkl. AskUserQuestion (Modus auto statt dontAsk, siehe unten). assert_contains "$planner" "role-id: planner" assert_contains "$planner" "role-version: 4.2.0" planner_catalog_version=$( jq -r '.roles[] | select(.id == "planner") | .version' "$catalog" ) [[ $planner_catalog_version == 4.2.0 ]] || fail "unerwartete Planner-Version im Katalog" planner_catalog_sandbox=$(jq -r '.roles[] | select(.id == "planner") | .sandbox' "$catalog") [[ $planner_catalog_sandbox == scoped-write ]] || fail "Planner ist nicht als scoped-write katalogisiert" assert_contains "$planner" "implementierst nichts selbst" assert_contains "$planner" "Intent-Dokument" assert_contains "$planner" "docs/intent/" assert_contains "$planner" "direkt und persönlich mit dem Nutzer" assert_contains "$planner" "Rohfassung" assert_contains "$planner" "direkt im Pane ausdrücklich bestätigt" # Bedingte Design-Zwischenstufe (SDLC-Playbook, Punkt 1): beide positiven # Trigger, der negative Erwähnungsfall sowie Nutzerbestätigung vor dem finalen # Intent-Schreiben sind ein zusammenhängender Textvertrag. planner_design_stage=$(sed -n '/^### Bedingte Design-Zwischenstufe/,/^## Session-Abschluss/p' "$planner" | tr '\n' ' ' | tr -s ' ') assert_text_contains "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Das Vorhaben berührt **mehrere Hosts**" assert_text_contains "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Das Vorhaben berührt die **Rollen-/Katalog-Grenze**" assert_text_contains "Planner-Design-Zwischenstufe" "$planner_design_stage" \ '`agents/catalog.json`, an einer `agents/*.md`-Rollendatei oder an `agents/claude-settings/*.json`' assert_text_order "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Die bloße Erwähnung einer Rolle im Fließtext" "löst den Schritt **nicht** aus" assert_text_order "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Trifft ein Auslöser zu, legst du dem Nutzer im Chat" \ "Diese Zusammenfassung lässt du ausdrücklich vom Nutzer bestätigen" assert_text_order "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Diese Zusammenfassung lässt du ausdrücklich vom Nutzer bestätigen" \ "bevor du zum finalen Intent-Dokument übergehst" assert_text_contains "Planner-Design-Zwischenstufe" "$planner_design_stage" \ "Es entsteht dabei **kein neues Artefakt**" assert_not_contains "$planner" "nachweislich gelesen" assert_not_contains "$planner" "BLOCKED: Rückfragen" # Planner ist als einzige Rolle im direkten Dialog: gleiche Self-Close- # Mechanik wie die übrigen Rollen, aber Freigabe kommt vom Nutzer im Pane # statt vom Orchestrator. assert_contains "$planner" 'Session-Abschluss (Self-Close)' assert_contains "$planner" '/etc/scripts/close-my-pane.sh' assert_contains "$planner" '$HERDR_PANE_ID' assert_contains "$planner" 'ignoriert jedes Argument' assert_contains "$planner" '**eigene**' assert_contains "$planner" 'niemals eine fremde' assert_contains "$planner" 'bleibst idle' assert_contains "$planner" 'Fehlt eine Bedingung' assert_contains "$planner" '**Codex 0.145:**' assert_contains "$planner" '`sandbox_permissions=require_escalated`' assert_contains "$planner" '`workspace-write` wird blockiert' assert_contains "$planner" 'Keine Argumente, kein' assert_contains "$planner" '**Claude:**' assert_contains "$planner" 'wegen des Sessionlimits noch aus' wrapper_grants_planner=$(jq --arg grant "$self_close_grant" \ '[.permissions.allow[] | select(. == $grant)] | length' \ "$settings_dir/planner.json") [[ $wrapper_grants_planner -eq 1 ]] \ || fail "planner.json: erwarteter Self-Close-Wrapper fehlt" # Seit 4.0.0 läuft der Planner im Modus auto statt dontAsk (dontAsk blockt # AskUserQuestion strukturell, unabhängig von der Allow-Liste; live # beobachtet, siehe Version 3.0.1). auto erlaubt das Tool, verlässt sich für # ungelistete Aufrufe aber auf einen Klassifikator statt auf eine feste # Liste - ein bewusst auf diese Rolle begrenzter Pilotwechsel, kein Rollout. # Der Grant muss deshalb jetzt genau einmal vorhanden sein. jq -e '[.permissions.allow[] | select(. == "AskUserQuestion")] | length == 1' \ "$settings_dir/planner.json" >/dev/null \ || fail "Planner braucht den AskUserQuestion-Grant (Modus auto seit 4.0.0)" assert_contains "$planner" 'echter interaktiver' assert_contains "$planner" 'Klassifikator' assert_contains "$planner" 'Pilotwechsel' assert_contains "$planner" '`dontAsk`' assert_contains "$planner" '`AskUserQuestion`' assert_contains "$planner" '`auto`' jq -e '[.permissions.allow[] | select(startswith("Edit("))] == ["Edit(docs/intent/*.md)"]' \ "$settings_dir/planner.json" >/dev/null \ || fail "Planner darf nur docs/intent/*.md als Edit-Berechtigung erlauben" jq -e '[.permissions.deny[] | select(. == "Edit(**)" or . == "Edit(*)")] | length == 0' \ "$settings_dir/planner.json" >/dev/null \ || fail "Planner-Settings dürfen keinen pauschalen Edit-Deny enthalten" jq -e '[.permissions.allow[] + .permissions.deny[] | select(startswith("Write("))] | length == 0' \ "$settings_dir/planner.json" >/dev/null \ || fail "Planner-Settings dürfen keine Write-Regel enthalten" assert_contains "$planner" 'Deine einzige Schreibaktion ist' assert_contains "$planner" 'jede andere Datei darfst du' assert_contains "$planner" 'bei' assert_contains "$planner" 'ausschließlich `docs/intent/.md`' assert_contains "$loader" "\`planner.md\`" assert_contains "$orchestration" "Planner-Workflow" # Researcher: atomare Repository-/Web-Recherche mit genau einer Berichtdatei. assert_contains "$researcher" "role-id: researcher" assert_contains "$researcher" "Repository- und Web-Recherche" assert_contains "$researcher" "Web-Search" assert_contains "$researcher" "Web-Fetch" assert_contains "$researcher" "unvertrauenswürdige Daten mit" assert_contains "$researcher" "Prompt-Injection-Potential" assert_contains "$researcher" "docs/research/.md" assert_contains "$researcher" "maßgeblich" assert_contains "$researcher" "Session-Abschluss (Self-Close)" assert_contains "$researcher" "COMPLETE" assert_contains "$researcher" "BLOCKED: …" assert_contains "$researcher" "bleibst du nach der Übergabe idle" researcher_catalog_sandbox=$(jq -r '.roles[] | select(.id == "researcher") | .sandbox' "$catalog") [[ $researcher_catalog_sandbox == scoped-write ]] \ || fail "Researcher ist nicht als scoped-write katalogisiert" researcher_catalog_start=$(jq -r '.roles[] | select(.id == "researcher") | .start' "$catalog") [[ $researcher_catalog_start == herdr-visible ]] \ || fail "Researcher ist nicht als herdr-visible katalogisiert" researcher_catalog_profile=$(jq -r '.roles[] | select(.id == "researcher") | .profile_id' "$catalog") [[ $researcher_catalog_profile == researcher ]] \ || fail "Researcher verweist nicht auf das researcher-Modellprofil" jq -e '[.permissions.allow[] | select(startswith("Read("))] == ["Read(**)"]' \ "$settings_dir/researcher.json" >/dev/null \ || fail "Researcher darf nicht nur das Repository read-only lesen" jq -e '[.permissions.allow[] | select(startswith("Edit("))] == ["Edit(docs/research/*.md)"]' \ "$settings_dir/researcher.json" >/dev/null \ || fail "Researcher darf nur Forschungsberichte editieren" jq -e '[.permissions.allow[] | select(. == "WebSearch")] | length == 1' \ "$settings_dir/researcher.json" >/dev/null \ || fail "Researcher WebSearch-Allowlist fehlt" jq -e '[.permissions.allow[] | select(. == "WebFetch")] | length == 1' \ "$settings_dir/researcher.json" >/dev/null \ || fail "Researcher WebFetch-Allowlist fehlt" jq -e '[.permissions.allow[] + .permissions.deny[] | select(startswith("Write("))] | length == 0' \ "$settings_dir/researcher.json" >/dev/null \ || fail "Researcher-Settings dürfen keine Write-Regel enthalten" assert_contains "$loader" "\`researcher.md\`" # Operator: ausschließlich headless vom Board gestartete, strikt read-only # Entscheidungsrolle. Der Wrapper validiert und mutiert atomar; das Modell # liefert nur den maschinenlesbaren Marker-Block. assert_contains "$operator" "role-id: operator" assert_contains "$operator" "role-version: 1.0.0" operator_catalog_sandbox=$(jq -r '.roles[] | select(.id == "operator") | .sandbox' "$catalog") [[ $operator_catalog_sandbox == read-only ]] \ || fail "Operator ist nicht als read-only katalogisiert" operator_catalog_start=$(jq -r '.roles[] | select(.id == "operator") | .start' "$catalog") [[ $operator_catalog_start == board-headless ]] \ || fail "Operator ist nicht als board-headless katalogisiert" operator_catalog_profile=$(jq -r '.roles[] | select(.id == "operator") | .profile_id' "$catalog") [[ $operator_catalog_profile == operator ]] \ || fail "Operator verweist nicht auf das operator-Modellprofil" assert_contains "$operator" "strikt read-only" assert_contains "$operator" "Ein vertrauenswürdiger Board-Wrapper" assert_contains "$operator" '===OPERATOR-DECISION===' assert_contains "$operator" '===END-OPERATOR-DECISION===' for decision in direct_builder split needs_human; do assert_contains "$operator" "$decision" done for field in \ schema_version decision reason recommended_lane runtime model \ reasoning_effort human_action children slug title wave blocked_by \ write_scope acceptance_criteria intent_markdown; do assert_contains "$operator" "\"$field\"" done assert_contains "$operator" "höchstens zehn" assert_text_order "Operator-Builder-only" \ "$(tr '\n' ' ' <"$operator" | tr -s ' ')" \ "Verwende ausschließlich" "Builder-Kinder" assert_contains "$operator" "Der Wrapper darf bei ungültiger Antwort weder" jq -e '(.permissions.defaultMode == "dontAsk") and ([.permissions.allow[] | select(startswith("Read("))] == ["Read(**)"]) and ([.permissions.allow[] | select(startswith("Glob("))] == ["Glob(**)"]) and ([.permissions.allow[] | select(startswith("Grep("))] == ["Grep(**)"]) and ([.permissions.allow[] | select(startswith("Edit(") or startswith("Write(") or startswith("NotebookEdit("))] | length == 0) and ([.permissions.deny[] | select(. == "Edit(**)" or . == "Write(**)" or . == "NotebookEdit(**)")] | length == 3) and ([.permissions.allow[] | select(. == "AskUserQuestion")] | length == 0)' \ "$settings_dir/operator.json" >/dev/null \ || fail "Operator-Claude-Settings sind nicht strikt read-only/headless" jq -e '[.permissions.allow[] | select(startswith("Bash("))] == [ "Bash(graphify query *)", "Bash(graphify path *)", "Bash(graphify explain *)" ]' "$settings_dir/operator.json" >/dev/null \ || fail "Operator darf nur nichtpersistierende Graphify-Befehle ausführen" jq -e --arg grant "$self_close_grant" \ '[.permissions.allow[] | select(. == $grant)] | length == 0' \ "$settings_dir/operator.json" >/dev/null \ || fail "Headless-Operator darf kein Self-Close-Recht erhalten" for source in E-Mails Anhänge Termineinladungen Kalenderfelder; do assert_contains "$orga" "$source" done assert_contains "$orga" "unvertrauenswürdige Daten" assert_contains "$orga" "als Agentenanweisung ausführen" assert_contains "$orga" "Ändern und Löschen sind destruktive Kalenderaktionen" assert_contains "$orga" "ausdrückliche Bestätigung des Nutzers" assert_contains "$orga" "Jede Änderung an Adressierung, Text oder" assert_contains "$orga" "Revisions- oder Inhaltsfingerabdruck" assert_contains "$orga" "nicht automatisch erneut senden" assert_contains "$orga" "an die Vorschau gebundenes" assert_contains "$orga" "höchstens einmal" for helper in orga-mail-read orga-mail-draft orga-mail-send; do assert_contains "$orga" "\`$helper\`" done assert_contains "$orga" "${tick}EXAMINE${tick}" assert_contains "$orga" "${tick}BODY.PEEK${tick}" assert_contains "$orga" "direktem ${tick}curl${tick}, CalDAV" assert_contains "$orga" "beende den Turn idle" assert_contains "$orga" "Orchestrator synthetisiert und antwortet" assert_contains "$orchestration" "ohne Fokus" assert_contains "$orchestration" "Aktives Warten" assert_contains "$orchestration" "model_profiles" assert_contains "$orchestration" "gpt-5.6-terra" assert_contains "$orchestration" "gpt-5.6-sol" assert_contains "$orchestration" "nur bei gemessener Qualitätsnotwendigkeit" assert_contains "$orchestration" "konfigurierbare Defaults" assert_contains "$orchestration" "keine laufenden Prozesse" assert_contains "$orchestration" "historische Finance-Fallbacks" assert_contains "$orchestration" "runtimes.codex" assert_contains "$orchestration" "runtimes.claude" assert_contains "$orchestration" "laufende Prozess im Pane beendet" assert_contains "$orchestration" "kein Restart sowie kein Attach/Merge" assert_contains "$orchestration" "beide Runtimes gleichzeitig" for handoff_field in \ correlation_id status missing_command desired_operation fixed_target \ minimal_right proof requested_backchannel payload_sha256 one_shot; do assert_contains "$orga" "$handoff_field:" done for handoff_state in \ requested available denied needs_trusted_confirmation completed failed; do assert_contains "$orga" "${tick}${handoff_state}${tick}" done assert_contains "$orga" "technische Anforderung und niemals" assert_contains "$orga" "Capability-Handoff" assert_contains "$orga" "orga-cal-20260901-hdm-online-plenum-v1" assert_contains "$orga" "capabilities|events" assert_contains "$orga" "dieselbe ${tick}correlation_id${tick}" assert_contains "$orga" "öffentliche Ziel ${tick}calendar/public${tick}" assert_contains "$orga" "direktem ${tick}curl${tick}, CalDAV, IMAP, SMTP" handoff_field() { local payload=$1 local field=$2 sed -n "s/^${field}: //p" <<<"$payload" } validate_capability_handoff() { local payload=$1 local field value status fixed_target minimal_right backchannel local required_fields=( correlation_id status missing_command desired_operation fixed_target minimal_right proof requested_backchannel payload_sha256 one_shot ) for field in "${required_fields[@]}"; do [[ $(grep -Ec "^${field}: .+" <<<"$payload") -eq 1 ]] || return 1 done status=$(handoff_field "$payload" status) case "$status" in requested|available|denied|needs_trusted_confirmation|completed|failed) ;; *) return 1 ;; esac fixed_target=$(handoff_field "$payload" fixed_target) [[ $fixed_target != *calendar/public* ]] || return 1 minimal_right=$(handoff_field "$payload" minimal_right) [[ $minimal_right =~ ^[a-z0-9._-]+:one-shot$ ]] || return 1 case "$minimal_right" in all:one-shot|any:one-shot|rw:one-shot|write:one-shot) return 1 ;; esac backchannel=$(handoff_field "$payload" requested_backchannel) [[ $backchannel =~ ^herdr:[a-zA-Z0-9._-]+$ ]] || return 1 value=$(handoff_field "$payload" payload_sha256) [[ $value =~ ^[a-f0-9]{64}$ ]] || return 1 [[ $(handoff_field "$payload" one_shot) == true ]] || return 1 } capability_allows_execution() { local payload=$1 local expected_correlation=$2 local expected_hash=$3 local expected_target=$4 local expected_backchannel=$5 validate_capability_handoff "$payload" || return 1 [[ $(handoff_field "$payload" status) == available ]] || return 1 [[ $(handoff_field "$payload" correlation_id) == "$expected_correlation" ]] \ || return 1 [[ $(handoff_field "$payload" payload_sha256) == "$expected_hash" ]] || return 1 [[ $(handoff_field "$payload" fixed_target) == "$expected_target" ]] || return 1 [[ $(handoff_field "$payload" requested_backchannel) == "$expected_backchannel" ]] \ || return 1 [[ $(handoff_field "$payload" proof) == *capability_check=unambiguous* ]] \ || return 1 [[ $(handoff_field "$payload" proof) =~ resolved_target_id=calendar-id:private:[a-zA-Z0-9._-]+ ]] \ || return 1 } valid_handoff='correlation_id: orga-cal-test-v1 status: available missing_command: orga-calendar-create desired_operation: calendar.event.create fixed_target: private-calendar-ref:orga-owner minimal_right: calendar.event.create:one-shot proof: capability_check=unambiguous; resolved_target_id=calendar-id:private:abc123; command=create requested_backchannel: herdr:orga payload_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa one_shot: true' expected_correlation='orga-cal-test-v1' expected_hash=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa expected_target=private-calendar-ref:orga-owner expected_backchannel=herdr:orga validate_capability_handoff "$valid_handoff" \ || fail "gültiger Capability-Handoff wurde abgelehnt" capability_allows_execution \ "$valid_handoff" "$expected_correlation" "$expected_hash" \ "$expected_target" "$expected_backchannel" \ || fail "eindeutig verfügbarer Capability-Handoff wurde abgelehnt" assert_handoff_rejected() { local name=$1 local payload=$2 if validate_capability_handoff "$payload"; then fail "$name wurde nicht abgelehnt" fi } assert_execution_rejected() { local name=$1 local payload=$2 if capability_allows_execution \ "$payload" "$expected_correlation" "$expected_hash" \ "$expected_target" "$expected_backchannel"; then fail "Ausführung für $name wurde nicht abgelehnt" fi } assert_handoff_rejected "pauschale Rechteforderung" \ "${valid_handoff/calendar.event.create:one-shot/rw}" assert_handoff_rejected "calendar/public als Zielersatz" \ "${valid_handoff/private-calendar-ref:orga-owner/calendar\/public}" assert_handoff_rejected "fehlende correlation_id" \ "$(sed '/^correlation_id:/d' <<<"$valid_handoff")" assert_handoff_rejected "fehlender status" \ "$(sed '/^status:/d' <<<"$valid_handoff")" assert_handoff_rejected "fehlender Backchannel" \ "$(sed '/^requested_backchannel:/d' <<<"$valid_handoff")" ambiguous_handoff=${valid_handoff/capability_check=unambiguous/capability_check=ambiguous} failed_handoff=${valid_handoff/status: available/status: failed} assert_execution_rejected "mehrdeutige Capability-Prüfung" "$ambiguous_handoff" assert_execution_rejected "fehlgeschlagene Capability-Prüfung" "$failed_handoff" assert_execution_rejected "abweichende correlation_id" \ "${valid_handoff/orga-cal-test-v1/orga-cal-other-v1}" assert_execution_rejected "abweichenden Payload-Hash" \ "${valid_handoff/$expected_hash/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" assert_execution_rejected "abweichendes festes Ziel" \ "${valid_handoff/private-calendar-ref:orga-owner/private-calendar-ref:other}" assert_contains "$admin" "role-id: administrator" admin_catalog_version=$( jq -r '.roles[] | select(.id == "administrator") | .version' "$catalog" ) [[ $admin_catalog_version == 3.1.0 ]] \ || fail "unerwartete Administrator-Version im Katalog" # Eskalationsanlass im Ergebnisformat (SDLC-Playbook, Punkt 3): Administrator # hat kein Datei-Artefakt, der Anlass steht in der Chat-Übergabe. assert_contains "$admin" "Eskalationsanlass" assert_contains "$admin" "gemessener Qualitätsnotwendigkeit" assert_contains "$admin" "kein Datei-Artefakt" assert_contains "$admin" "/home/drone/nixos-config" assert_contains "$admin" "maßgebliche" assert_contains "$admin" "Source of Truth" for managed_area in Paketen Diensten Benutzern Netzwerk Berechtigungen Deployment; do assert_contains "$admin" "$managed_area" done assert_contains "$admin" "NixOS-, Home-Manager- oder Flake-Konfiguration" assert_contains "$admin" "deklarativ ändern, risikogerecht prüfen" assert_contains "$admin" "Freigabe aktivieren" assert_contains "$admin" "read-only Diagnose" assert_contains "$admin" "zeitlich und sachlich begrenzten" assert_contains "$admin" "Notfalländerung" assert_contains "$admin" "als Konfigurationsdrift" assert_contains "$admin" "zurückgenommen oder in der deklarativen Konfiguration" assert_contains "$admin" "keinen undokumentierten Konfigurationsdrift" assert_contains "$admin" "Keine bekannte Konfigurationsdrift" assert_contains "$admin" "Dateibesitz muss exklusiv sein" assert_contains "$admin" "Keine Klartext-Secrets" assert_contains "$admin" "Keine destruktiven Git-Befehle" assert_contains "$admin" "im Auftrag ausdrücklich" printf 'OK: Rollenkatalog und Sicherheitsgrenzen\n'