fix: harden copilot reviewer acceptance path
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
---
|
||||
model: claude-sonnet-4.5
|
||||
model_family: anthropic
|
||||
description: ARIS reviewer agent using Anthropic Claude Sonnet 4.5 for cross-family review
|
||||
tools: read
|
||||
---
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
---
|
||||
model: gpt-5.4
|
||||
model_family: openai
|
||||
description: ARIS reviewer agent using OpenAI GPT-5.4 for cross-family review
|
||||
tools: read
|
||||
---
|
||||
|
||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
@@ -76,6 +77,48 @@ def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def model_family(model: str) -> str:
|
||||
"""Derive a known provider family from a model identity, failing closed."""
|
||||
name = (model or "").strip().lower()
|
||||
families: set[str] = set()
|
||||
if re.search(r"(^|[^a-z0-9])(gpt|chatgpt|codex|oracle|o1|o3|o4)([^a-z0-9]|$)", name):
|
||||
families.add("openai")
|
||||
if re.search(r"(^|[^a-z0-9])(claude|sonnet|opus|haiku|anthropic)([^a-z0-9]|$)", name):
|
||||
families.add("anthropic")
|
||||
if re.search(r"(^|[^a-z0-9])(gemini|google)([^a-z0-9]|$)", name):
|
||||
families.add("google")
|
||||
return next(iter(families)) if len(families) == 1 else "unknown"
|
||||
|
||||
|
||||
def reviewer_model_from_response(response: str) -> str | None:
|
||||
"""Read the mandatory first-line identity without trusting prose later on."""
|
||||
first_line = response.splitlines()[0].strip() if response.splitlines() else ""
|
||||
match = re.fullmatch(r"Reviewer-Model:\s*(\S(?:.*\S)?)", first_line, re.IGNORECASE)
|
||||
return match.group(1).strip() if match else None
|
||||
|
||||
|
||||
def validate_reviewer_identity(response: str, config: dict) -> str | None:
|
||||
"""Return an error when strict cross-family manual review cannot be proven."""
|
||||
if not config.get("require_reviewer_model"):
|
||||
return None
|
||||
executor_model = str(config.get("executor_model") or "").strip()
|
||||
executor_family = model_family(executor_model)
|
||||
if executor_family == "unknown":
|
||||
return "Cannot verify manual review: executor_model is missing or has an unknown family"
|
||||
reviewer_model = reviewer_model_from_response(response)
|
||||
if not reviewer_model:
|
||||
return "Manual response must begin with: Reviewer-Model: <exact-model-id>"
|
||||
reviewer_family = model_family(reviewer_model)
|
||||
if reviewer_family == "unknown":
|
||||
return f"Cannot verify manual reviewer model family: {reviewer_model}"
|
||||
if reviewer_family == executor_family:
|
||||
return (
|
||||
"Manual reviewer must use a different model family: "
|
||||
f"executor={executor_family}, reviewer={reviewer_family}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def load_ui_html() -> str:
|
||||
global _UI_HTML
|
||||
if _UI_HTML is None:
|
||||
@@ -328,6 +371,10 @@ class _ReviewHandler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
session = _current_session
|
||||
if session:
|
||||
identity_error = validate_reviewer_identity(response_text, session.config)
|
||||
if identity_error:
|
||||
self.send_error(400, identity_error)
|
||||
return
|
||||
session.response = response_text
|
||||
session.done.set()
|
||||
self.send_response(200)
|
||||
@@ -343,15 +390,26 @@ class _ReviewHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
FILE_MODE_WARNING = """# ARIS Manual Review - Cross-Model Warning
|
||||
|
||||
If this workflow is running from Claude Code, do NOT paste this prompt into any Claude product (claude.ai, Claude API, Claude App). Using the same model family as executor defeats the purpose of ARIS cross-model review.
|
||||
Use a reviewer from a DIFFERENT model family than the executor. A same-family response cannot satisfy an ARIS acceptance gate.
|
||||
|
||||
如果此流程由 Claude Code 执行,请勿将此提示词粘贴到任何 Claude 产品。请使用 ChatGPT、DeepSeek、Kimi、Gemini、Qwen、本地模型或其他非 Claude 模型。
|
||||
请使用与执行器不同模型家族的评审模型;同家族回复不能通过 ARIS 验收门。
|
||||
|
||||
---
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def file_mode_warning(config: dict) -> str:
|
||||
header = FILE_MODE_WARNING
|
||||
if config.get("require_reviewer_model"):
|
||||
executor_model = str(config.get("executor_model") or "unknown")
|
||||
header += (
|
||||
f"Executor model: `{executor_model}` (derived family: `{model_family(executor_model)}`).\n\n"
|
||||
"The response MUST begin with `Reviewer-Model: <exact-model-id>`.\n\n---\n\n"
|
||||
)
|
||||
return header
|
||||
|
||||
|
||||
def wait_for_browser_response(prompt: str, config: dict, thread_id: str,
|
||||
history: list, cancel_event: threading.Event,
|
||||
cancel_reason: str) -> tuple[str | None, str | None]:
|
||||
@@ -456,7 +514,7 @@ def wait_for_file_response(prompt: str, config: dict, thread_id: str,
|
||||
response_path.unlink()
|
||||
|
||||
# Write prompt with cross-model warning
|
||||
header = FILE_MODE_WARNING
|
||||
header = file_mode_warning(config)
|
||||
header += f"<!-- thread: {thread_id} | config: {json.dumps(config)} -->\n\n"
|
||||
if history:
|
||||
header += "## Previous Exchanges\n\n"
|
||||
@@ -505,6 +563,10 @@ def wait_for_file_response(prompt: str, config: dict, thread_id: str,
|
||||
prev_content = None
|
||||
continue
|
||||
if content == prev_content:
|
||||
identity_error = validate_reviewer_identity(content, config)
|
||||
if identity_error:
|
||||
error = identity_error
|
||||
break
|
||||
response = content
|
||||
break
|
||||
prev_content = content
|
||||
@@ -520,6 +582,10 @@ def wait_for_file_response(prompt: str, config: dict, thread_id: str,
|
||||
prev_content = None
|
||||
continue
|
||||
if content2 == content and content2:
|
||||
identity_error = validate_reviewer_identity(content2, config)
|
||||
if identity_error:
|
||||
error = identity_error
|
||||
break
|
||||
response = content2
|
||||
break
|
||||
prev_content = content2
|
||||
|
||||
@@ -53,14 +53,14 @@ footer { padding: 10px 24px; border-top: 1px solid var(--border); background: va
|
||||
|
||||
<div class="warning-banner" id="warningBanner">
|
||||
<strong id="warningTitle">Cross-Model Review Required</strong>
|
||||
<span id="warningText">If you are running this from Claude Code, do NOT paste the prompt into any Claude product (claude.ai, Claude API, Claude App). Using the same model family as executor defeats the purpose of cross-model review. Use ChatGPT, DeepSeek, Kimi, Gemini, or any non-Claude model.</span>
|
||||
<span id="warningText">Use a reviewer from a DIFFERENT model family than the executor. Same-family review cannot satisfy an ARIS acceptance gate.</span>
|
||||
</div>
|
||||
|
||||
<details class="tutorial" id="tutorial">
|
||||
<summary id="tutorialTitle">How to use</summary>
|
||||
<ol>
|
||||
<li id="step1">Copy the prompt from the left panel</li>
|
||||
<li id="step2">Paste it into a DIFFERENT model family (ChatGPT, DeepSeek, Kimi, Gemini, etc.)</li>
|
||||
<li id="step2">Paste it into a model from a DIFFERENT family than the executor shown above</li>
|
||||
<li id="step3">Copy the model's full response</li>
|
||||
<li id="step4">Paste it into the right panel and click Submit</li>
|
||||
</ol>
|
||||
@@ -107,10 +107,11 @@ const i18n = {
|
||||
en: {
|
||||
title: "Manual Review — ARIS",
|
||||
warningTitle: "Cross-Model Review Required",
|
||||
warningText: "If you are running this from Claude Code, do NOT paste the prompt into any Claude product (claude.ai, Claude API, Claude App). Using the same model family as executor defeats the purpose of cross-model review. Use ChatGPT, DeepSeek, Kimi, Gemini, or any non-Claude model.",
|
||||
warningText: "Use a reviewer from a DIFFERENT model family than the executor. Same-family review cannot satisfy an ARIS acceptance gate.",
|
||||
warningStrict: "Executor {model} has family {family}. Use a different family, and begin the response with: Reviewer-Model: <exact-model-id>",
|
||||
tutorialTitle: "How to use",
|
||||
step1: "Copy the prompt from the left panel",
|
||||
step2: "Paste it into a DIFFERENT model family (ChatGPT, DeepSeek, Kimi, Gemini, etc.)",
|
||||
step2: "Paste it into a model from a DIFFERENT family than the executor shown above",
|
||||
step3: "Copy the model's full response",
|
||||
step4: "Paste it into the right panel and click Submit",
|
||||
promptLabel: "Review Prompt",
|
||||
@@ -130,10 +131,11 @@ const i18n = {
|
||||
zh: {
|
||||
title: "手动评审 — ARIS",
|
||||
warningTitle: "必须使用不同模型家族",
|
||||
warningText: "如果你从 Claude Code 运行此流程,请勿将提示词粘贴到任何 Claude 产品(claude.ai、Claude API、Claude App)。使用与执行器相同的模型家族会使跨模型评审失去意义。请使用 ChatGPT、DeepSeek、Kimi、Gemini 或其他非 Claude 模型。",
|
||||
warningText: "请使用与执行器不同模型家族的评审模型;同家族评审不能通过 ARIS 验收门。",
|
||||
warningStrict: "执行器 {model} 属于 {family} 家族。请使用其他家族,并让回复第一行为:Reviewer-Model: <准确模型 ID>",
|
||||
tutorialTitle: "使用方法",
|
||||
step1: "从左侧面板复制提示词",
|
||||
step2: "粘贴到不同模型家族(ChatGPT、DeepSeek、Kimi、Gemini 等)",
|
||||
step2: "粘贴到与上方所示执行器不同家族的模型",
|
||||
step3: "复制模型的完整回复",
|
||||
step4: "粘贴到右侧面板并点击提交",
|
||||
promptLabel: "评审提示词",
|
||||
@@ -156,6 +158,15 @@ let lang = navigator.language.startsWith("zh") ? "zh" : "en";
|
||||
let ctx = {};
|
||||
let token = new URLSearchParams(window.location.search).get("token") || "";
|
||||
|
||||
function modelFamily(model) {
|
||||
const name = String(model || "").toLowerCase();
|
||||
const families = new Set();
|
||||
if (/(^|[^a-z0-9])(gpt|chatgpt|codex|oracle|o1|o3|o4)([^a-z0-9]|$)/.test(name)) families.add("openai");
|
||||
if (/(^|[^a-z0-9])(claude|sonnet|opus|haiku|anthropic)([^a-z0-9]|$)/.test(name)) families.add("anthropic");
|
||||
if (/(^|[^a-z0-9])(gemini|google)([^a-z0-9]|$)/.test(name)) families.add("google");
|
||||
return families.size === 1 ? Array.from(families)[0] : "unknown";
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const res = await fetch("/api/context?token=" + token);
|
||||
ctx = await res.json();
|
||||
@@ -199,7 +210,11 @@ function applyLang() {
|
||||
const t = i18n[lang];
|
||||
document.getElementById("title").textContent = t.title;
|
||||
document.getElementById("warningTitle").textContent = t.warningTitle;
|
||||
document.getElementById("warningText").textContent = t.warningText;
|
||||
const cfg = ctx.config || {};
|
||||
const executorModel = cfg.executor_model || "unknown";
|
||||
document.getElementById("warningText").textContent = cfg.require_reviewer_model
|
||||
? t.warningStrict.replace("{model}", executorModel).replace("{family}", modelFamily(executorModel))
|
||||
: t.warningText;
|
||||
document.getElementById("tutorialTitle").textContent = t.tutorialTitle;
|
||||
document.getElementById("step1").textContent = t.step1;
|
||||
document.getElementById("step2").textContent = t.step2;
|
||||
@@ -209,7 +224,9 @@ function applyLang() {
|
||||
document.getElementById("responseLabel").textContent = t.responseLabel;
|
||||
document.getElementById("copyBtn").textContent = t.copyBtn;
|
||||
document.getElementById("submitBtn").textContent = t.submitBtn;
|
||||
document.getElementById("responseArea").placeholder = t.placeholder;
|
||||
document.getElementById("responseArea").placeholder = cfg.require_reviewer_model
|
||||
? "Reviewer-Model: <exact-model-id>\n\n" + t.placeholder
|
||||
: t.placeholder;
|
||||
document.getElementById("historyTitle").textContent = t.historyTitle;
|
||||
document.getElementById("successMsg").textContent = t.successMsg;
|
||||
const effort = (ctx.config || {}).model_reasoning_effort || "xhigh";
|
||||
|
||||
@@ -55,8 +55,15 @@ When calling the reviewer, branch on REVIEWER_BACKEND:
|
||||
- executor_family=unknown → `REVIEW_UNAVAILABLE` (fail closed).
|
||||
**Verify the profile file** exists at `.github/agents/<profile>.agent.md`.
|
||||
If missing → `REVIEW_UNAVAILABLE`.
|
||||
**Read its `model:` field** into `REVIEWER_MODEL`, derive `reviewer_family`
|
||||
from that model string, and verify it differs from `executor_family`. Pass
|
||||
the same value through subprocess `--model`; never trust a caller-supplied
|
||||
family label or profile-only pinning under an Auto session.
|
||||
**Capability gate:** `copilot --help` must advertise `--model`, `--effort`,
|
||||
and `--allow-tool`; otherwise emit `REVIEW_UNAVAILABLE`.
|
||||
**Use the `copilot --agent` subprocess** (documented Copilot CLI form)
|
||||
with the selected profile for each review call.
|
||||
with the selected profile, `--model "$REVIEWER_MODEL"`, `--effort xhigh`,
|
||||
and `--allow-tool=read` for each review call.
|
||||
**Multi-round:** each round is a fresh `copilot --agent` call with the same
|
||||
profile; reviewer memory is carried via `REVIEWER_MEMORY.md` artifact.
|
||||
If `copilot` CLI is unavailable → `REVIEW_UNAVAILABLE` (no MCP-dependent
|
||||
@@ -70,14 +77,18 @@ When calling the reviewer, branch on REVIEWER_BACKEND:
|
||||
**If REVIEWER_BACKEND = `manual`:**
|
||||
Use `mcp__manual_review__review` for new review threads with:
|
||||
prompt: [exact same prompt that would go to Codex]
|
||||
config: {"model_reasoning_effort": "xhigh"}
|
||||
config: {"model_reasoning_effort": "xhigh", "executor_model": "<actual executor model>", "require_reviewer_model": true}
|
||||
Save the returned `threadId`.
|
||||
Use `mcp__manual_review__review_reply` for follow-up rounds with:
|
||||
threadId: [saved manual-review threadId]
|
||||
prompt: [follow-up prompt]
|
||||
config: {"model_reasoning_effort": "xhigh"}
|
||||
config: {"model_reasoning_effort": "xhigh", "executor_model": "<actual executor model>", "require_reviewer_model": true}
|
||||
A verdict-bearing manual response MUST begin with
|
||||
`Reviewer-Model: <exact-model-id>`. Derive `reviewer_family` from that model
|
||||
identity. Missing, unknown, or same-family identity cannot acquit; for a
|
||||
mandatory escalation, emit `REVIEW_UNAVAILABLE` rather than guessing.
|
||||
|
||||
Prompt fidelity: the manual prompt must be exactly the same text that Codex would receive.
|
||||
Prompt fidelity: the manual review task must be exactly the same text that Codex would receive; the transport may add only the required `Reviewer-Model:` response-format instruction.
|
||||
Review tracing applies equally to both backends.
|
||||
|
||||
## State Persistence (Compact Recovery)
|
||||
@@ -106,7 +117,7 @@ Long-running loops may hit the context window limit, triggering automatic compac
|
||||
|
||||
- **`run_id`** — Globally unique per invocation. Generated on fresh start as `run_<YYYYMMDD>_<8-char-hex>` (e.g., `run_20260713_a1b2c3d4`). Preserved across round writes. On resume, read from state file unchanged. This binds all round state, reviewer-memory appends, and acquittal receipts to one run so a stale completed state from a previous invocation cannot leak into the current run's acquittal check.
|
||||
|
||||
When REVIEWER_BACKEND = `copilot`, save `reviewer_profile` (the custom agent profile name used), `executor_model` (from `--executor-model`), `executor_family` (derived), `reviewer_family` (from profile's pinned model), and `independence_verified: true` (confirmed `executor_family != reviewer_family`). Copilot tasks do not return a persistent agent ID — each round is a fresh `task` call with the same profile. For `codex` backend, save `threadId` (Codex MCP thread ID). For `manual` backend, save `threadId` (manual-review thread ID). On resume, use the `reviewer_backend` field to determine the correct continuation mechanism (fresh `task` call with profile for copilot, `codex-reply` for codex, `manual_review_reply` for manual).
|
||||
When REVIEWER_BACKEND = `copilot`, save `reviewer_profile`, `requested_reviewer_model`, `executor_model`, and the model-derived `executor_family`, `reviewer_family`, and `independence_verified`. Copilot does not return a persistent agent ID — each round is a fresh `copilot --agent` subprocess with the same profile/model. For `codex` backend, save `threadId` (Codex MCP thread ID). For `manual` backend, save `threadId` and the declared reviewer model identity. On resume, use the `reviewer_backend` field to determine the correct continuation mechanism (fresh `copilot --agent` subprocess for copilot, `codex-reply` for codex, `manual_review_reply` for manual).
|
||||
|
||||
**Write this file at the end of every Phase E** (after documenting the round). Overwrite each time — only the latest round's state matters. The `run_id` field MUST persist unchanged across overwrites within the same run.
|
||||
|
||||
@@ -117,7 +128,7 @@ When REVIEWER_BACKEND = `copilot`, save `reviewer_profile` (the custom agent pro
|
||||
In addition to the overwritable state file, maintain an **append-only** acquittal log at `review-stage/ACQUITTAL_LOG.jsonl`. Each line is a standalone JSON object recording an acquitting positive verdict:
|
||||
|
||||
```jsonl
|
||||
{"run_id":"run_20260713_a1b2c3d4","round":3,"backend":"codex","effort":"xhigh","verdict":"ready","score":7.5,"trace_id":"auto-review-loop/2026-07-13_run03","timestamp":"2026-07-13T14:22:00Z"}
|
||||
{"run_id":"run_20260713_a1b2c3d4","round":3,"backend":"codex","effort":"xhigh","verdict":"ready","score":7.5,"executor_model":"claude-sonnet-4-5","executor_family":"anthropic","reviewer_model":"gpt-5.6-sol","reviewer_family":"openai","independence_verified":true,"trace_id":"auto-review-loop/2026-07-13_run03","timestamp":"2026-07-13T14:22:00Z"}
|
||||
```
|
||||
|
||||
**Rules (non-negotiable):**
|
||||
@@ -129,6 +140,7 @@ In addition to the overwritable state file, maintain an **append-only** acquitta
|
||||
| **When to write** | At the end of Phase E, immediately after a positive verdict (score >= 6 AND verdict ∈ {"ready", "almost"}) from a qualifying backend. |
|
||||
| **`run_id` binding** | Every acquittal line carries the current `run_id`. The stop-evaluation gate for Copilot MUST verify `run_id` matches the current run before accepting an acquittal. |
|
||||
| **Trace linkage** | `trace_id` MUST reference a trace artifact in `.aris/traces/` (per Review Tracing protocol) so every acquittal is independently verifiable. |
|
||||
| **Identity linkage** | Copy executor/reviewer model identities from that trace. Re-derive both families from the model strings; both must be known, different, and match the trace's `independence_verified: true`. Caller-supplied family strings alone never qualify. |
|
||||
| **No overwrite** | `REVIEW_STATE.json` is overwritten each round (only latest state). `ACQUITTAL_LOG.jsonl` is NEVER overwritten — it is the permanent, cumulative record. |
|
||||
|
||||
**Why this exists:** `REVIEW_STATE.json` is overwritten each round (only the latest state matters per the contract above). When a run completes with `status: "completed"` and a codex/manual positive verdict, a subsequent fresh-start invocation writes a new `REVIEW_STATE.json` — obliterating the prior run's acquittal data. The Copilot stop-evaluation gate (Phase B.5.1) must check for an acquittal from the **current run**, not a stale state file. The append-only `ACQUITTAL_LOG.jsonl`, with `run_id` matching, is the only reliable source of truth.
|
||||
@@ -157,7 +169,7 @@ In addition to the overwritable state file, maintain an **append-only** acquitta
|
||||
- Read `review-stage/AUTO_REVIEW.md` to restore full context of prior rounds *(fall back to `./AUTO_REVIEW.md`)*
|
||||
- If `pending_experiments` is non-empty, check if they have completed (e.g., check screen sessions)
|
||||
- Resume from the next round (round = saved round + 1)
|
||||
- Use `reviewer_backend` to determine continuation: `codex-reply` for codex, fresh `task` call with saved `reviewer_profile` for copilot, `manual_review_reply` for manual
|
||||
- Use `reviewer_backend` to determine continuation: `codex-reply` for codex, fresh `copilot --agent` subprocess with the saved profile/model for copilot, `manual_review_reply` for manual
|
||||
- Log: "Recovered from context compaction. Resuming at Round N."
|
||||
2. Read project narrative documents, memory files, and any prior review documents. **When `COMPACT = true` and compact files exist**: read `findings.md` + `EXPERIMENT_LOG.md` instead of full `review-stage/AUTO_REVIEW.md` and raw logs — saves context window.
|
||||
3. Read recent experiment results (check output directories, logs)
|
||||
@@ -187,9 +199,13 @@ If REVIEWER_BACKEND = `copilot`, enforce cross-family invariant FIRST:
|
||||
- `google` → `"aris-reviewer-openai"` (openai default)
|
||||
- Verify the profile file exists at `.github/agents/<profile>.agent.md`.
|
||||
If missing → `REVIEW_UNAVAILABLE`. Stop.
|
||||
- Read the profile's first frontmatter `model:` value, derive its family, and
|
||||
verify it is known and differs from `executor_family`. If not, fail closed.
|
||||
- Verify `copilot --help` exposes `--model`, `--effort`, and `--allow-tool`.
|
||||
Older/unpinned CLIs are `REVIEW_UNAVAILABLE`.
|
||||
- Adapt the Codex MCP calls below to use the **`copilot --agent`** subprocess
|
||||
(documented Copilot CLI form):
|
||||
- Replace `mcp__codex__codex` with `copilot --agent "<profile>" --prompt "..."`
|
||||
- Replace `mcp__codex__codex` with `copilot --agent "<profile>" --model "<parsed-model>" --effort xhigh --allow-tool=read --prompt "..."`
|
||||
- Each round is a fresh `copilot --agent` call with the same profile +
|
||||
`REVIEWER_MEMORY.md` artifact carrying round-to-round state.
|
||||
- The prompt text and Review Tracing are identical to the Codex path.
|
||||
@@ -235,7 +251,7 @@ mcp__codex__codex:
|
||||
up and is ready, say so clearly.
|
||||
```
|
||||
|
||||
*For manual backend:* use `mcp__manual_review__review` with the `prompt` text above and `config: {"model_reasoning_effort": "xhigh"}`. Save the returned `threadId`.
|
||||
*For manual backend:* use `mcp__manual_review__review` with the `prompt` text above and `config: {"model_reasoning_effort": "xhigh", "executor_model": "<actual executor model>", "require_reviewer_model": true}`. Save the returned `threadId`.
|
||||
|
||||
If this is round 2+, use `mcp__codex__codex-reply` (codex) or `mcp__manual_review__review_reply` (manual) with the saved threadId.
|
||||
|
||||
@@ -369,14 +385,14 @@ After parsing the assessment, append to `REVIEWER_MEMORY.md` in the project root
|
||||
|
||||
#### Phase B.5.1: Stop-Evaluation Gate
|
||||
|
||||
**STOP CONDITION — branch by REVIEWER_BACKEND:**
|
||||
**STOP CONDITION — branch by `round_backend` (the backend that actually ran this round), never by the forward-looking `REVIEWER_BACKEND`:**
|
||||
|
||||
- **If REVIEWER_BACKEND ∈ {codex, manual}:** If score >= 6 AND verdict ∈ {"ready", "almost"} (exact match — "not ready" does NOT qualify) → stop loop, document final state. (The acquittal line is recorded in Phase E — this gate decides, Phase E documents. Do NOT write the acquittal line here.)
|
||||
- **If REVIEWER_BACKEND = copilot:** Copilot is drive-only (effort-unpinned, per Key Rules). Do NOT stop on a copilot-issued positive verdict unless a `codex` or `manual` backend at `xhigh`+ effort has already issued an acquitting positive verdict **in this same run**. To check: scan `review-stage/ACQUITTAL_LOG.jsonl` for a line whose `run_id` matches the **current** `run_id` AND `backend` ∈ {codex, manual} AND `effort` (case-insensitive) equals `"xhigh"` AND `verdict` ∈ {"ready", "almost"} AND `score` >= 6 AND `trace_id` is non-empty AND the trace directory `.aris/traces/<trace_id>/` exists on disk. If such an acquittal exists: stop. If no same-run acquittal exists AND copilot returned a positive verdict (score >= 6, verdict ∈ {"ready", "almost"}): the loop MUST escalate — schedule the next round to use a cross-family backend for a mandatory acquittal review.
|
||||
- **If `round_backend ∈ {codex, manual}`:** If score >= 6 AND verdict ∈ {"ready", "almost"} (exact match — "not ready" does NOT qualify), first verify the current call's trace contains known executor/reviewer model identities whose model-derived families differ and `independence_verified` is `true`. A manual response must also contain its required `Reviewer-Model:` identity. If provenance is missing, unknown, same-family, or inconsistent, emit `REVIEW_UNAVAILABLE`; otherwise stop and let Phase E write exactly one receipt. (This gate decides; Phase E documents. Do NOT write the acquittal line here.)
|
||||
- **If `round_backend = copilot`:** Copilot is drive-only by policy. Do NOT stop on a copilot-issued positive verdict unless a `codex` or `manual` backend at `xhigh` effort has already issued an independently verified positive verdict **in this same run**. Scan `review-stage/ACQUITTAL_LOG.jsonl` and accept a line only when all of these hold: its `run_id` matches the current run; `backend` is `codex` or `manual`; effort is exactly `xhigh` (case-insensitive); verdict/score are qualifying; `executor_model` and `reviewer_model` are non-empty; re-deriving their families gives the stored known families; the stored executor family equals the current executor's model-derived family; reviewer family differs; `independence_verified` is exactly `true`; and `trace_id` names an existing `.aris/traces/<trace_id>/` whose request/meta identity fields match the receipt. Never trust receipt family strings without re-deriving them from the models. If a valid acquittal exists: stop. If none exists and Copilot returned a positive verdict, schedule the next round on a cross-family backend for mandatory acquittal.
|
||||
|
||||
**Escalation backend selection (cross-family check):** Determine the escalation backend by comparing `executor_family` (derived from `--executor-model` in Phase A):
|
||||
- `executor_family = anthropic` or `google` → escalate to `codex` (codex uses GPT models = openai family, guaranteed cross-family from non-openai executors).
|
||||
- `executor_family = openai` → escalate to `manual` instead of `codex` (codex is same-family, defeating the cross-family acquittal guarantee). Manual is the terminal escalation — no codex fallback (which would reopen the same-family acquittal gap). If manual is unavailable, emit `REVIEW_UNAVAILABLE`.
|
||||
- `executor_family = openai` → escalate to `manual` instead of `codex` (codex is same-family, defeating the cross-family acquittal guarantee). Manual is terminal: require the response's exact `Reviewer-Model:` identity, derive a known non-OpenAI family from it, and persist the same identity in the trace/receipt. Missing, unknown, or OpenAI-family identity is `REVIEW_UNAVAILABLE`; there is no codex fallback.
|
||||
- `executor_family = unknown` → `REVIEW_UNAVAILABLE` (fail closed — cannot guarantee cross-family acquittal).
|
||||
|
||||
**State snapshot before escalation:** `round_backend` was already snapshotted at round start (step 0) and equals `"copilot"`. Update `reviewer_backend` in `REVIEW_STATE.json` to the selected escalation backend for the next round, and note in `AUTO_REVIEW.md` that the copilot drive triggered a mandatory cross-family acquittal. Phase E uses `round_backend` (still `"copilot"`) to label which backend ran the CURRENT round. If copilot returned a negative verdict: continue to next round with copilot backend as usual. Copilot NEVER writes an acquittal line itself.
|
||||
@@ -416,20 +432,29 @@ Send the executor's rebuttal back to the reviewer for a ruling:
|
||||
|
||||
*For copilot:* fresh `copilot --agent` subprocess with the same profile + REVIEWER_MEMORY.md context:
|
||||
```bash
|
||||
# Write assembled prompt to a temp file to avoid shell injection
|
||||
# from untrusted REVIEWER_MEMORY.md content (which may contain quotes,
|
||||
# backticks, or $() that would be re-interpreted in a double-quoted arg)
|
||||
PROMPTFILE=$(mktemp) || PROMPTFILE="/tmp/reviewer_prompt_$$.txt"
|
||||
# Store the generated rebuttal as data; never paste memory/rebuttal text into
|
||||
# a heredoc body, because either may contain a line matching its delimiter.
|
||||
MEMORY_FILE="REVIEWER_MEMORY.md"
|
||||
REBUTTAL_FILE="review-stage/ROUND_${ROUND}_REBUTTAL.md"
|
||||
[[ -f "$MEMORY_FILE" && -f "$REBUTTAL_FILE" ]] || {
|
||||
echo "REVIEW_UNAVAILABLE: missing memory or rebuttal artifact" >&2
|
||||
exit 1
|
||||
}
|
||||
PROMPTFILE="$(mktemp)" || { echo "REVIEW_UNAVAILABLE: mktemp failed" >&2; exit 1; }
|
||||
trap 'rm -f "$PROMPTFILE"' EXIT
|
||||
cat > "$PROMPTFILE" <<'PROMPT_EOF'
|
||||
[Rebutal ruling — same reviewer]
|
||||
{
|
||||
cat <<'ARIS_REBUTTAL_HEADER'
|
||||
[Rebuttal ruling — same reviewer]
|
||||
|
||||
## Your Memory From Previous Rounds
|
||||
[Paste full contents of REVIEWER_MEMORY.md]
|
||||
ARIS_REBUTTAL_HEADER
|
||||
cat -- "$MEMORY_FILE"
|
||||
cat <<'ARIS_REBUTTAL_MIDDLE'
|
||||
|
||||
The author rebuts your review:
|
||||
|
||||
[paste executor's rebuttal]
|
||||
ARIS_REBUTTAL_MIDDLE
|
||||
cat -- "$REBUTTAL_FILE"
|
||||
cat <<'ARIS_REBUTTAL_FOOTER'
|
||||
|
||||
For each rebuttal, rule:
|
||||
- SUSTAINED (author's argument is valid, withdraw this weakness)
|
||||
@@ -438,8 +463,10 @@ For each rebuttal, rule:
|
||||
|
||||
Then update your score if any weaknesses were withdrawn.
|
||||
Include a Memory Update section at the end of your response.
|
||||
PROMPT_EOF
|
||||
copilot --agent "<saved-reviewer-profile>" --prompt "$(cat "$PROMPTFILE")"
|
||||
ARIS_REBUTTAL_FOOTER
|
||||
} > "$PROMPTFILE"
|
||||
copilot --agent "$REVIEWER_PROFILE" --model "$REVIEWER_MODEL" \
|
||||
--effort xhigh --allow-tool=read --prompt "$(cat "$PROMPTFILE")"
|
||||
```
|
||||
|
||||
*For codex:*
|
||||
@@ -614,9 +641,9 @@ This is the authoritative record. Do NOT truncate or paraphrase.]
|
||||
|
||||
**If `round_backend ∈ {codex, manual}` AND score >= 6 AND verdict ∈ {"ready", "almost"}:** append an acquittal line to `review-stage/ACQUITTAL_LOG.jsonl`:
|
||||
```
|
||||
{"run_id":"<current-run_id>","round":<N>,"backend":"<codex|manual>","effort":"xhigh","verdict":"<ready|almost>","score":<score>,"trace_id":"<skill>/<YYYY-MM-DD>_run<NN>","timestamp":"<ISO8601>"}
|
||||
{"run_id":"<current-run_id>","round":<N>,"backend":"<codex|manual>","effort":"xhigh","verdict":"<ready|almost>","score":<score>,"executor_model":"<from-trace>","executor_family":"<derived-from-executor_model>","reviewer_model":"<from-trace-or-manual-Reviewer-Model>","reviewer_family":"<derived-from-reviewer_model>","independence_verified":true,"trace_id":"<skill>/<YYYY-MM-DD>_run<NN>","timestamp":"<ISO8601>"}
|
||||
```
|
||||
Use `>>` (append), never `>`. The `trace_id` MUST be the actual trace directory path relative to `.aris/traces/` (e.g., `auto-review-loop/2026-07-13_run01`), matching the RUN_ID format from `save_trace.sh`: `<YYYY-MM-DD>_run<NN>` with the skill-name subdirectory prefix. Do NOT fabricate a synthetic `trace_...` identifier — use the real directory that `save_trace.sh` created for this round's reviewer call.
|
||||
Use `>>` (append), never `>`. Write only after re-deriving both families from the trace's model identities and confirming they are known/different and the trace says `independence_verified: true`; never copy caller-provided family claims blindly. The `trace_id` MUST be the actual trace directory path relative to `.aris/traces/` (e.g., `auto-review-loop/2026-07-13_run01`), matching the RUN_ID format from `save_trace.sh`: `<YYYY-MM-DD>_run<NN>` with the skill-name subdirectory prefix. Do NOT fabricate a synthetic `trace_...` identifier — use the real directory that `save_trace.sh` created for this round's reviewer call.
|
||||
|
||||
**Append to `findings.md`** (when `COMPACT = true`): one-line entry per key finding this round:
|
||||
|
||||
@@ -651,7 +678,7 @@ When loop ends (positive assessment or max rounds):
|
||||
- **Large file handling**: If the Write tool fails due to file size, immediately retry using Bash (`cat << 'EOF' > file`) to write in chunks. Do NOT ask the user for permission — just do it silently.
|
||||
|
||||
- ALWAYS use `config: {"model_reasoning_effort": "xhigh"}` for maximum reasoning depth
|
||||
- **Copilot backend is drive-only (effort-unpinned).** Copilot profiles cannot set reasoning effort. Copilot verdicts are recorded as `effort_unpinned: true` and can iterate the loop, but final acceptance must come from a `codex` or `manual` backend at `xhigh`+ effort. Do not terminate the loop on a copilot-issued positive verdict without an acquitting cross-review.
|
||||
- **Copilot backend remains drive-only by policy.** Every Copilot call explicitly pins the parsed profile model with `--model`, pins `--effort xhigh`, grants only `--allow-tool=read`, and records `effort_unpinned: false`. Final acceptance still requires a separately traced `codex` or `manual` reviewer whose model-derived family differs from the executor family.
|
||||
- Save `threadId` (codex/manual) or `reviewer_profile` (copilot) from first call; use the appropriate continuation tool for subsequent rounds per the Reviewer Calling Convention
|
||||
- **Anti-hallucination citations**: When adding references during fixes, NEVER fabricate BibTeX. Use the same DBLP → CrossRef → `[VERIFY]` chain as `/paper-write`: (1) `curl -s "https://dblp.org/search/publ/api?q=TITLE&format=json"` → get key → `curl -s "https://dblp.org/rec/{key}.bib"`, (2) if not found, `curl -sLH "Accept: application/x-bibtex" "https://doi.org/{doi}"`, (3) if both fail, mark with `% [VERIFY]`. Do NOT generate BibTeX from memory.
|
||||
- Be honest — include negative results and failed experiments
|
||||
@@ -669,30 +696,39 @@ Use the selected backend. *For copilot:* fresh `copilot --agent` subprocess with
|
||||
```
|
||||
[For copilot:]
|
||||
|
||||
# Write assembled prompt to a temp file to avoid shell injection
|
||||
# from untrusted REVIEWER_MEMORY.md content (which may contain quotes,
|
||||
# backticks, or $() that would be re-interpreted in a double-quoted arg)
|
||||
PROMPTFILE=$(mktemp) || PROMPTFILE="/tmp/reviewer_prompt_$$.txt"
|
||||
# Dynamic values remain data; do not paste them into heredoc source.
|
||||
MEMORY_FILE="REVIEWER_MEMORY.md"
|
||||
CHANGED_PATHS="<newline-delimited changed paths>"
|
||||
DIFF_PATH="<diff artifact path or git range>"
|
||||
RESULT_PATHS="<newline-delimited result paths>"
|
||||
[[ -f "$MEMORY_FILE" ]] || { echo "REVIEW_UNAVAILABLE: missing reviewer memory" >&2; exit 1; }
|
||||
PROMPTFILE="$(mktemp)" || { echo "REVIEW_UNAVAILABLE: mktemp failed" >&2; exit 1; }
|
||||
trap 'rm -f "$PROMPTFILE"' EXIT
|
||||
cat > "$PROMPTFILE" <<'PROMPT_EOF'
|
||||
{
|
||||
cat <<'ARIS_ROUND_HEADER'
|
||||
[Round N update]
|
||||
|
||||
## Your Memory From Previous Rounds
|
||||
[Paste full contents of REVIEWER_MEMORY.md]
|
||||
ARIS_ROUND_HEADER
|
||||
cat -- "$MEMORY_FILE"
|
||||
cat <<'ARIS_ROUND_STATE'
|
||||
|
||||
Since your last review these files changed — read them yourself; do not
|
||||
take my word for what changed or whether it worked:
|
||||
- Changed files: <paths>
|
||||
- Raw diff: <path, or the `git diff` range>
|
||||
- Updated raw results: <result-file paths> (verbatim files, not a pasted table)
|
||||
ARIS_ROUND_STATE
|
||||
printf '%s\n' "Changed files:" "$CHANGED_PATHS" "Raw diff: $DIFF_PATH" \
|
||||
"Updated raw results:" "$RESULT_PATHS"
|
||||
cat <<'ARIS_ROUND_INSTRUCTIONS'
|
||||
|
||||
Please re-score and re-assess. Are the remaining concerns addressed?
|
||||
Same format: Score, Verdict, Remaining Weaknesses, Minimum Fixes.
|
||||
|
||||
At the end of your review, include a Memory Update section — this will
|
||||
be passed back to you next round.
|
||||
PROMPT_EOF
|
||||
copilot --agent "<saved-reviewer-profile>" --prompt "$(cat "$PROMPTFILE")"
|
||||
ARIS_ROUND_INSTRUCTIONS
|
||||
} > "$PROMPTFILE"
|
||||
copilot --agent "$REVIEWER_PROFILE" --model "$REVIEWER_MODEL" \
|
||||
--effort xhigh --allow-tool=read --prompt "$(cat "$PROMPTFILE")"
|
||||
|
||||
[For codex:] mcp__codex__codex-reply:
|
||||
threadId: [saved from round 1]
|
||||
@@ -728,9 +764,9 @@ The following test cases validate the `run_id` + append-only acquittal receipt m
|
||||
|
||||
### Test 2: Codex Acquits → Copilot Stops (Same Run, Mixed Backend)
|
||||
|
||||
**Setup:** Fresh start. Round 1: `REVIEWER_BACKEND=codex`, returns score=7, verdict="ready". Phase E writes acquittal line to `ACQUITTAL_LOG.jsonl` with current `run_id`, `effort: "xhigh"`, valid `trace_id` referencing an existing trace directory under `.aris/traces/auto-review-loop/`. Loop stops with `status: "completed"`. Simulate user re-entering: resume state, switch reviewer to copilot for round 2.
|
||||
**Setup:** Fault-injection recovery test. In one in-progress run, a cross-family codex call has written a positive receipt with current `run_id`, `effort: "xhigh"`, model-derived `executor_family=anthropic`, `reviewer_family=openai`, `independence_verified=true`, and a matching trace directory, but the process is interrupted before completion status is persisted. Resume the same run with its forward backend explicitly set to copilot.
|
||||
|
||||
**Action B:** Copilot round 2 returns score=8, verdict="ready". Stop gate scans `ACQUITTAL_LOG.jsonl` → finds line with matching `run_id`, `backend=codex`, `effort="xhigh"`, `verdict="ready"`, `score=7`, non-empty `trace_id` with existing trace directory. All gate predicates validated: match. Stop.
|
||||
**Action B:** Copilot returns score=8, verdict="ready". The stop gate re-derives both receipt families from their model strings and verifies the matching trace identity fields before accepting the same-run receipt.
|
||||
|
||||
**Expected:** Copilot-issued verdict terminates because a same-run codex acquittal with validated effort and trace exists in the append-only log.
|
||||
|
||||
@@ -758,19 +794,19 @@ The following test cases validate the `run_id` + append-only acquittal receipt m
|
||||
|
||||
### Test 6: Append-Only Integrity
|
||||
|
||||
**Setup:** Run with codex backend producing three rounds: round 1 (score=5), round 2 (score=7, "ready"), round 3 (score=8, "ready").
|
||||
**Setup:** Run 1 reaches a positive cross-family codex verdict and appends one receipt, then completes. Start Run 2 with a new `run_id`; it also reaches a positive verified verdict and appends one receipt.
|
||||
|
||||
**Action:** After the loop, inspect `ACQUITTAL_LOG.jsonl`.
|
||||
|
||||
**Expected:** File contains exactly 2 lines (round 2 and round 3 acquittals), each with the same `run_id`. Lines are never overwritten or deleted. File size monotonically increases.
|
||||
**Expected:** File contains exactly 2 lines with different run IDs. The Run 1 line remains byte-for-byte intact after Run 2 appends; file size increases monotonically. A single loop cannot produce a later round after a positive stop verdict.
|
||||
|
||||
### Test 7: Manual Backend Acquittal Works Same as Codex
|
||||
|
||||
**Setup:** Fresh start with `REVIEWER_BACKEND=manual`. Manual review returns score=7, verdict="ready".
|
||||
**Setup:** Fresh start with `REVIEWER_BACKEND=manual`. The response starts with an exact `Reviewer-Model:` identity whose derived family is known and differs from the executor family, and returns score=7, verdict="ready". The trace records those identities and `independence_verified=true`.
|
||||
|
||||
**Action:** Phase E appends to `ACQUITTAL_LOG.jsonl`.
|
||||
|
||||
**Expected:** Acquittal line with `backend=manual` is written. A subsequent copilot round in the same run would find this acquittal and stop.
|
||||
**Expected:** Acquittal line with `backend=manual` plus both model/family identities is written. Repeat with a missing, unknown, or same-family `Reviewer-Model:` value: no receipt is written and the mandatory path returns `REVIEW_UNAVAILABLE`.
|
||||
|
||||
### Test 8: Pure Copilot Run — Escalation Completes the Loop
|
||||
|
||||
|
||||
@@ -60,13 +60,13 @@ if [ -n "$TRACE_HELPER" ]; then
|
||||
--tool "<mcp__codex__codex | copilot --agent | mcp__manual_review__review | ...>" \
|
||||
--executor "<claude-code | copilot | codex>" \
|
||||
--executor-model "<from --executor-model; unavailable if not set>" \
|
||||
--executor-family "<openai | anthropic | google | unknown>" \
|
||||
--executor-family "<legacy consistency hint; helper re-derives from executor-model>" \
|
||||
--reviewer-profile "<profile name for copilot backend; empty for others>" \
|
||||
--reviewer-family "<openai | anthropic | google | unknown>" \
|
||||
--reviewer-family "<legacy consistency hint; helper re-derives from reviewer model>" \
|
||||
--requested-reviewer-model "<model originally requested>" \
|
||||
--reported-reviewer-model "<model the backend reports it used>" \
|
||||
--memory-hash "<sha256 of memory artifact if available; empty otherwise>" \
|
||||
--independence-verified "<true | false>" \
|
||||
--independence-verified "<legacy consistency hint; helper ignores and re-derives>" \
|
||||
--prompt "<full prompt as sent>" \
|
||||
--response "<full response content>"
|
||||
else
|
||||
@@ -87,10 +87,14 @@ else
|
||||
fi
|
||||
```
|
||||
|
||||
The helper, when present, handles directory creation, run numbering,
|
||||
and file writing. The fallback branch above documents what to do
|
||||
The helper, when present, handles directory creation, run numbering, file
|
||||
writing, and provenance derivation. It derives families from model strings
|
||||
(`reported_reviewer_model`, otherwise `requested_reviewer_model`, otherwise
|
||||
`model`) and ignores contradictory caller family/independence claims. The fallback branch above documents what to do
|
||||
when the helper is unreachable — the trace is forensic evidence, so
|
||||
"helper missing" never means "skip the trace."
|
||||
"helper missing" never means "skip the trace." A direct fallback writer MUST
|
||||
apply the same model-string derivation and mark either unknown family as
|
||||
`independence_verified: "unverified"`; it may not copy caller family labels.
|
||||
|
||||
## File Schemas
|
||||
|
||||
@@ -140,8 +144,8 @@ For copilot backend (`--reviewer: copilot`):
|
||||
"tool": "copilot --agent",
|
||||
"backend": "copilot",
|
||||
"model": "gpt-5.4",
|
||||
"effort_unpinned": true,
|
||||
"config": {},
|
||||
"effort": "xhigh",
|
||||
"effort_unpinned": false,
|
||||
"reviewer_profile": "aris-reviewer-openai",
|
||||
"requested_reviewer_model": "gpt-5.4",
|
||||
"reported_reviewer_model": null,
|
||||
@@ -157,13 +161,13 @@ For copilot backend (`--reviewer: copilot`):
|
||||
Fields:
|
||||
- `tool`: the tool name used (`mcp__codex__codex`, `copilot --agent`, `mcp__manual_review__review`, etc.).
|
||||
- `backend`: the logical backend (`codex`, `copilot`, `manual`, `oracle-pro`, `agy`).
|
||||
- `effort_unpinned`: `true` when the backend cannot pin reasoning effort (copilot profiles have no effort control); `false` otherwise.
|
||||
- `effort_unpinned`: for Copilot, `false` only when the actual subprocess was explicitly invoked with `--effort xhigh`; older/unpinned Copilot calls remain `true` and cannot meet the review floor.
|
||||
- `reviewer_profile`: for copilot, the custom agent profile name (e.g., `aris-reviewer-openai`); `null` for other backends.
|
||||
- `requested_reviewer_model`: the model pinned in the selected profile frontmatter (parsed from `.github/agents/<profile>.agent.md`); `null` when unavailable.
|
||||
- `requested_reviewer_model`: the model parsed from profile frontmatter and repeated through subprocess `--model`; `null` when unavailable.
|
||||
- `reported_reviewer_model`: the model the tool reports actually using; `null` when the tool does not surface this information.
|
||||
- `executor_model`: from `--executor-model` parameter; `"unavailable"` if not provided.
|
||||
- `executor_family`: derived from `executor_model`.
|
||||
- `reviewer_family`: the reviewer's model family (`openai` / `anthropic` / `google` / `unknown`).
|
||||
- `reviewer_family`: derived by the helper from the reported/requested/actual reviewer model, never trusted from the caller.
|
||||
- `independence_verified`: derived from actual executor and reviewer model families — `true` when `executor_family != reviewer_family` and both are known; `false` otherwise; `"unverified"` when families are not both available to derive.
|
||||
|
||||
### `NNN-<purpose>.response.md`
|
||||
@@ -195,7 +199,8 @@ For copilot backend:
|
||||
"thread_id": null,
|
||||
"model": "gpt-5.4",
|
||||
"model_family": "openai",
|
||||
"effort_unpinned": true,
|
||||
"effort": "xhigh",
|
||||
"effort_unpinned": false,
|
||||
"executor_family": "anthropic",
|
||||
"requested_reviewer_model": "gpt-5.4",
|
||||
"reported_reviewer_model": null,
|
||||
@@ -208,9 +213,9 @@ For copilot backend:
|
||||
|
||||
Fields new per this fix:
|
||||
- `model_family`: `openai` / `anthropic` / `google` / `unknown` — derived from the model that actually ran.
|
||||
- `effort_unpinned`: `true` when the backend cannot pin reasoning effort (copilot backend); `false` otherwise.
|
||||
- `effort_unpinned`: whether a Copilot call lacked the required explicit `xhigh` pin; qualifying current Copilot calls record `false`.
|
||||
- `executor_family`: from `--executor-model` derivation (copilot) or executor introspection (other backends); `"unavailable"` if not known.
|
||||
- `requested_reviewer_model`: the model pinned in the selected profile frontmatter; `null` when not available.
|
||||
- `requested_reviewer_model`: the profile model also passed through subprocess `--model`; `null` when not available.
|
||||
- `reported_reviewer_model`: the model the tool reports actually using; `null` when the tool does not surface this.
|
||||
- `independence_verified`: derived from actual executor and reviewer families — `true` only when they differ and both are known; `false` if same-family; `"unverified"` if families cannot be resolved.
|
||||
- `reviewer_profile`: for copilot backend only, the custom agent profile name; `null` for other backends.
|
||||
|
||||
@@ -11,7 +11,7 @@ The default reviewer backend depends on the skill AND the execution environment:
|
||||
|
||||
**Copilot CLI for `/auto-review-loop` is explicit opt-in only.** The `COPILOT_CLI` environment variable does not exist persistently (it is an open proposal at github/copilot-cli#2107, not a shipped feature). Until a reliable auto-detection signal ships, `--reviewer: copilot` must be passed explicitly. The default reviewer for `/auto-review-loop` is `codex` (preserving backward compatibility — no breaking change for existing Claude Code users).
|
||||
|
||||
See the [Copilot section](#copilot-cli-custom-agent-profiles---reviewer-copilot--default-for-auto-review-loop) for the auto-review-loop default and the [Codex section](#codex-capability-fallback-new-reviewer-sessions-only) for the Codex fallback chain.
|
||||
See the [Copilot section](#copilot-cli-custom-agent-profiles---reviewer-copilot--explicit-opt-in-for-auto-review-loop) for the auto-review-loop override and the [Codex section](#codex-capability-fallback-new-reviewer-sessions-only) for the Codex fallback chain.
|
||||
|
||||
### Codex MCP Tiered Reasoning-Effort Policy
|
||||
|
||||
@@ -188,13 +188,15 @@ If `— reviewer: manual`:
|
||||
→ Check if mcp__manual_review__review tool is available
|
||||
→ If available:
|
||||
Use mcp__manual_review__review with:
|
||||
prompt: [same prompt you would send to Codex]
|
||||
config: {"model_reasoning_effort": "xhigh"}
|
||||
prompt: [same review prompt you would send to Codex; the manual
|
||||
transport wrapper also requires the response's first line
|
||||
to be `Reviewer-Model: <exact-model-id>`]
|
||||
config: {"model_reasoning_effort": "xhigh", "executor_model": "<actual executor model>", "require_reviewer_model": true}
|
||||
For round 2+ in multi-round skills:
|
||||
Use mcp__manual_review__review_reply with:
|
||||
threadId: [saved from prior call]
|
||||
prompt: [follow-up prompt]
|
||||
config: {"model_reasoning_effort": "xhigh"}
|
||||
config: {"model_reasoning_effort": "xhigh", "executor_model": "<actual executor model>", "require_reviewer_model": true}
|
||||
→ If NOT available:
|
||||
Print: "⚠️ Manual Review MCP not installed. Install with: claude mcp add manual-review -s user -- python3 /path/to/mcp-servers/manual-review/server.py"
|
||||
STOP. Do NOT fall back to Codex (the target user likely has no Codex subscription).
|
||||
@@ -203,8 +205,8 @@ If `— reviewer: manual`:
|
||||
### Invariants
|
||||
|
||||
- `— reviewer: manual` ONLY takes effect when explicitly passed
|
||||
- **Cross-model family is mandatory, not optional.** "any model" above means any *non-executor-family* model. When the executor is Claude (the normal case), the user MUST paste the prompt into a non-Claude model (ChatGPT / DeepSeek / Kimi / Gemini / a local model) — never any Claude product. Pasting into Claude makes Claude judge Claude, which silently voids the cross-model invariant and the verdict is worthless. The manual-review UI surfaces this as a banner; the routing contract requires it. A Type-B acceptance gate (`acceptance-gate.md`) is satisfied by `manual` only when the routed model is verifiably non-Claude.
|
||||
- Prompt fidelity: the user sees the EXACT same prompt text that Codex would receive
|
||||
- **Cross-model family is mandatory, not optional.** "any model" above means any *non-executor-family* model, determined dynamically from the actual executor model — not merely "non-Claude." Every verdict-bearing response must start with `Reviewer-Model: <exact-model-id>`; derive its family and compare it with the model-derived executor family. Missing, unknown, ambiguous, or same-family identity is `REVIEW_UNAVAILABLE` for an acceptance gate. The UI warning is advisory; the traced model identities and derivation are the gate.
|
||||
- Prompt fidelity: the review task text is exactly what Codex would receive; the manual transport wrapper may add only the model-identity response-format line used by the provenance gate
|
||||
- `config.model_reasoning_effort` is shown as a recommendation badge, not embedded in the prompt
|
||||
- Thread continuity: `review_reply` shows previous exchanges so the user can maintain context in their chosen model
|
||||
- Reviewer independence protocol still applies
|
||||
@@ -285,7 +287,7 @@ Both profiles must exist and be loadable by the Copilot CLI (`copilot --agent`).
|
||||
|
||||
The maintainer requires: **reviewer model family MUST differ from executor model family.** Same-family review is forbidden regardless of circumstance. There is no "provisional" acceptance.
|
||||
|
||||
Unlike the previous broken model-inheritance approach (where a non-GPT executor would silently get a same-family subagent), **each profile pins its model explicitly in the profile file** — the subagent does NOT inherit the executor model.
|
||||
Unlike the previous broken model-inheritance approach, the router reads the selected profile's `model:` field and passes that same value through the subprocess-level `--model` flag. This outer pin is mandatory because Copilot CLI may ignore a custom agent's model when the session model is Auto.
|
||||
|
||||
**Family detection requires `--executor-model`:**
|
||||
|
||||
@@ -328,15 +330,17 @@ The auto-review-loop SKILL.md MUST accept `--executor-model <model>` as a parame
|
||||
- `executor_model` comes from `--executor-model` (verified: the executor declares it).
|
||||
- `executor_family` is derived from `executor_model` via the rules above.
|
||||
- `reviewer_profile` is the profile name selected by the router.
|
||||
- `requested_reviewer_model` is the model pinned in the selected profile file.
|
||||
- `requested_reviewer_model` is read from the selected profile file and passed explicitly with `--model`.
|
||||
- `reported_reviewer_model` is what the copilot CLI reports (if it surfaces this — otherwise `"unavailable"`).
|
||||
- `reviewer_family` is derived from `reported_reviewer_model` (if available) or from the profile's declared family.
|
||||
- `reviewer_family` is derived from `reported_reviewer_model` (if available) or `requested_reviewer_model`; caller-supplied family labels are never trusted.
|
||||
- `independence_verified` is `true` only when `executor_family != reviewer_family`; `false` otherwise (which must not happen if the router rule is followed).
|
||||
|
||||
**Fail closed when:**
|
||||
- `--executor-model` is missing AND `--reviewer: copilot` is used → `REVIEW_UNAVAILABLE`.
|
||||
- `executor_family` is `unknown` → `REVIEW_UNAVAILABLE`.
|
||||
- The selected profile file does not exist → `REVIEW_UNAVAILABLE`.
|
||||
- The profile has no non-empty `model:` field, or the derived reviewer family is unknown/same-family → `REVIEW_UNAVAILABLE`.
|
||||
- `copilot --help` does not advertise `--model`, `--effort`, and `--allow-tool` → `REVIEW_UNAVAILABLE`; do not silently run an older unpinned CLI.
|
||||
- `independence_verified` is `false` → re-check; if confirmed same-family, `REVIEW_UNAVAILABLE`.
|
||||
|
||||
### Routing Logic
|
||||
@@ -361,12 +365,17 @@ If `--reviewer: copilot` (explicit opt-in):
|
||||
Print: "⚠️ Custom agent profile '<profile>.agent.md' not found.
|
||||
Create it at .github/agents/<profile>.agent.md with model: <model>."
|
||||
Emit REVIEW_UNAVAILABLE. Stop.
|
||||
→ Read the first frontmatter `model:` value as requested_reviewer_model.
|
||||
Derive reviewer_family from that model string and confirm it differs from
|
||||
executor_family. Never trust a free-form model_family field.
|
||||
→ Verify `copilot` CLI is available (`command -v copilot`). If not:
|
||||
Print: "⚠️ --reviewer: copilot requires Copilot CLI (`copilot` command)."
|
||||
Emit REVIEW_UNAVAILABLE. Do NOT fall back to Codex MCP or manual-review MCP
|
||||
(the user chose copilot because they may have no MCP access —
|
||||
MCP-dependent fallbacks would fail silently).
|
||||
→ Use `copilot --agent` with the selected profile for each review round.
|
||||
→ Verify the CLI supports `--model`, `--effort`, and `--allow-tool`.
|
||||
→ Use `copilot --agent` with the selected profile, the parsed model,
|
||||
`--effort xhigh`, and read-only tool permission for each review round.
|
||||
|
||||
If no `--reviewer:` specified:
|
||||
→ Default to Codex MCP (`codex` backend).
|
||||
@@ -382,15 +391,39 @@ If no `--reviewer:` specified:
|
||||
For the copilot reviewer, use the documented `copilot --agent` subprocess form with custom agent profiles:
|
||||
|
||||
```bash
|
||||
# Write assembled prompt to a temp file to avoid shell injection
|
||||
# from untrusted prompt content (quotes, backticks, $() re-interpretation)
|
||||
PROMPTFILE=$(mktemp)
|
||||
# PROFILE_FILE is the router-selected .agent.md file.
|
||||
REVIEWER_MODEL="$(python3 - "$PROFILE_FILE" <<'PY'
|
||||
import pathlib, sys
|
||||
lines = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8").splitlines()
|
||||
models = []
|
||||
if lines and lines[0].strip() == "---":
|
||||
for line in lines[1:]:
|
||||
if line.strip() == "---":
|
||||
break
|
||||
if line.startswith("model:"):
|
||||
models.append(line.split(":", 1)[1].strip().strip("\"'"))
|
||||
if len(models) == 1:
|
||||
print(models[0])
|
||||
PY
|
||||
)"
|
||||
[[ -n "$REVIEWER_MODEL" ]] || { echo "REVIEW_UNAVAILABLE: profile has no model" >&2; exit 1; }
|
||||
COPILOT_HELP="$(copilot --help 2>&1)" || { echo "REVIEW_UNAVAILABLE: copilot unavailable" >&2; exit 1; }
|
||||
for required_flag in --model --effort --allow-tool; do
|
||||
grep -q -- "$required_flag" <<<"$COPILOT_HELP" || {
|
||||
echo "REVIEW_UNAVAILABLE: copilot lacks $required_flag" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# Build the complete round prompt as a data file using the host's file-writing
|
||||
# API. Never render untrusted paths/content into shell source.
|
||||
REVIEW_TASK_FILE="review-stage/ROUND_${ROUND}_COPILOT_PROMPT.md"
|
||||
[[ -f "$REVIEW_TASK_FILE" ]] || { echo "REVIEW_UNAVAILABLE: missing prompt artifact" >&2; exit 1; }
|
||||
PROMPTFILE="$(mktemp)" || { echo "REVIEW_UNAVAILABLE: mktemp failed" >&2; exit 1; }
|
||||
trap 'rm -f "$PROMPTFILE"' EXIT
|
||||
cat > "$PROMPTFILE" <<'PROMPT_EOF'
|
||||
[Same review prompt as Codex MCP — role, task, output schema, file paths]
|
||||
Read the listed files directly.
|
||||
PROMPT_EOF
|
||||
copilot --agent "aris-reviewer-openai" --prompt "$(cat "$PROMPTFILE")"
|
||||
cat -- "$REVIEW_TASK_FILE" > "$PROMPTFILE"
|
||||
copilot --agent "$REVIEWER_PROFILE" --model "$REVIEWER_MODEL" \
|
||||
--effort xhigh --allow-tool=read --prompt "$(cat "$PROMPTFILE")"
|
||||
```
|
||||
|
||||
The profile name is the router-selected opposite-family profile (`aris-reviewer-openai` or `aris-reviewer-claude`).
|
||||
@@ -410,29 +443,38 @@ The profile name is the router-selected opposite-family profile (`aris-reviewer-
|
||||
**Pattern for round 2+:**
|
||||
|
||||
```bash
|
||||
# Write assembled prompt to a temp file to avoid shell injection
|
||||
# from untrusted REVIEWER_MEMORY.md content (quotes, backticks, $() re-interpretation)
|
||||
PROMPTFILE=$(mktemp)
|
||||
# These variables are data. Do not splice their values into shell source.
|
||||
CHANGED_PATHS="<newline-delimited changed paths>"
|
||||
DIFF_PATH="<diff artifact path>"
|
||||
RESULT_PATHS="<newline-delimited result paths>"
|
||||
MEMORY_FILE="review-stage/REVIEWER_MEMORY.md"
|
||||
[[ -f "$MEMORY_FILE" ]] || { echo "REVIEW_UNAVAILABLE: missing reviewer memory" >&2; exit 1; }
|
||||
PROMPTFILE="$(mktemp)" || { echo "REVIEW_UNAVAILABLE: mktemp failed" >&2; exit 1; }
|
||||
trap 'rm -f "$PROMPTFILE"' EXIT
|
||||
cat > "$PROMPTFILE" <<'PROMPT_EOF'
|
||||
{
|
||||
cat <<'ARIS_MEMORY_HEADER'
|
||||
[Round N/MAX_ROUNDS]
|
||||
|
||||
## Your Memory From Previous Rounds
|
||||
[Paste full contents of REVIEWER_MEMORY.md]
|
||||
ARIS_MEMORY_HEADER
|
||||
cat -- "$MEMORY_FILE"
|
||||
cat <<'ARIS_MEMORY_FOOTER'
|
||||
|
||||
## Current State
|
||||
Since your last review these files changed — read them yourself:
|
||||
- Changed files: <paths>
|
||||
- Raw diff: <path>
|
||||
- Updated raw results: <result-file paths>
|
||||
ARIS_MEMORY_FOOTER
|
||||
printf '%s\n' "Changed files:" "$CHANGED_PATHS" "Raw diff: $DIFF_PATH" "Updated raw results:" "$RESULT_PATHS"
|
||||
cat <<'ARIS_MEMORY_INSTRUCTIONS'
|
||||
|
||||
Please re-score and re-assess. Are the remaining concerns addressed?
|
||||
Same format: Score, Verdict, Remaining Weaknesses, Minimum Fixes.
|
||||
|
||||
At the end of your review, write (or append to) the Memory Update section
|
||||
in your response — this will be passed back to you next round.
|
||||
PROMPT_EOF
|
||||
copilot --agent "<same profile as round 1>" --prompt "$(cat "$PROMPTFILE")"
|
||||
ARIS_MEMORY_INSTRUCTIONS
|
||||
} > "$PROMPTFILE"
|
||||
copilot --agent "$REVIEWER_PROFILE" --model "$REVIEWER_MODEL" \
|
||||
--effort xhigh --allow-tool=read --prompt "$(cat "$PROMPTFILE")"
|
||||
```
|
||||
|
||||
**IMPORTANT:** This is architecturally different from `SendMessage` (which would require a persistent subagent handle that `copilot --agent` does not provide). The memory-artifact pattern is the documented alternative for stateful multi-round workflows in Copilot CLI.
|
||||
@@ -442,10 +484,10 @@ copilot --agent "<same profile as round 1>" --prompt "$(cat "$PROMPTFILE")"
|
||||
| Capability | Codex MCP | Copilot `--agent` + profiles | Status |
|
||||
|-----------|-----------|--------------------------|--------|
|
||||
| Task spawning | `mcp__codex__codex` | `copilot --agent` subprocess (documented Copilot CLI form) | **Verified** — in Copilot CLI docs |
|
||||
| Model pinning | `gpt-5.6-sol` param | `profile` -> pinned model in agent profile file | **Verified** — custom agent profiles are documented |
|
||||
| Model pinning | `gpt-5.6-sol` param | Profile model repeated as subprocess `--model` | **Verified** — prevents Auto-session inheritance |
|
||||
| Cross-model family | Configurable (agy, manual, llm-chat) | Router picks opposite-family profile | **Verified** — enforced by router logic |
|
||||
| Thread continuity | `codex-reply` (threadId) | New `copilot --agent` call + REVIEWER_MEMORY.md artifact | **Verified** — memory-artifact pattern |
|
||||
| Reasoning effort control | `xhigh` / `ultra` tiers | Not exposed; depends on profile defaults | **Known** — profiles have no effort control |
|
||||
| Reasoning effort control | `xhigh` / `ultra` tiers | Subprocess `--effort xhigh` | **Verified** — capability-gated before review |
|
||||
| File reading | Reads listed files | Can Read files via tools | **Verified** — task subagents have file access |
|
||||
| Review tracing | `.aris/traces/` schema | Same artifact schema + executor/reviewer family fields | **Verified** — trace schema updated for copilot backend |
|
||||
|
||||
@@ -458,21 +500,21 @@ copilot --agent "<same profile as round 1>" --prompt "$(cat "$PROMPTFILE")"
|
||||
| Profile pins model in frontmatter | **Verified** | Agent profile format from Copilot CLI docs |
|
||||
| `copilot --agent` runs synchronously | **Verified** | Returns response to stdout; confirmed by live testing |
|
||||
| Memory-artifact multi-round pattern | **Verified** | Standard for stateless subprocess-based workflows; REVIEWER_MEMORY.md carries state |
|
||||
| Reasoning effort in profile | **Known limitation** | Not exposed in profiles; copilot verdicts are `effort_unpinned: true` |
|
||||
| Reasoning effort for Copilot | **Verified** | Explicit subprocess `--effort xhigh`; unsupported CLIs fail closed |
|
||||
|
||||
### Invariants
|
||||
|
||||
- `--reviewer: copilot` is an **explicit opt-in** for `/auto-review-loop`. The default reviewer backend is `codex` (backward compatible). Use `--reviewer: codex` or `--reviewer: copilot` to select.
|
||||
- `--executor-model` is **MANDATORY** when `--reviewer: copilot` is used. Fail closed if missing.
|
||||
- **Cross-family invariant is MANDATORY**: router picks opposite-family profile. Same-family → `REVIEW_UNAVAILABLE`. Never "provisional".
|
||||
- **Review floor: copilot is drive-only, not acquit.** Copilot profiles pin `gpt-5.4` / `claude-sonnet-4.5` without reasoning-effort control — all verdicts from this backend are `effort_unpinned`. Copilot can iterate (drive the loop), but a `codex` or `manual` backend at `xhigh`+ effort must acquit before acceptance. Copilot-issued verdicts record `effort_unpinned: true` in trace metadata.
|
||||
- Custom agent profiles pin models explicitly — the subagent does NOT inherit the executor model.
|
||||
- **Review floor: copilot remains drive-only by policy.** Copilot calls are now pinned to `xhigh` and record `effort_unpinned: false`, but final acceptance still requires an independently traced `codex` or `manual` acquittal from a reviewer family different from the executor family.
|
||||
- Custom agent profile models are repeated with subprocess `--model`; do not rely on profile-only pinning under an Auto session.
|
||||
- Explicit reviewer directives (`codex`, `oracle-pro`, `agy`, `manual`) are separate from copilot.
|
||||
- Reviewer independence protocol still applies (pass file paths, not summaries).
|
||||
- `effort` and `difficulty` are orthogonal — they don't change the reviewer backend.
|
||||
- If `copilot` CLI is unavailable → `REVIEW_UNAVAILABLE` (no MCP-dependent fallback).
|
||||
- If executor family is unknown → `REVIEW_UNAVAILABLE` (fail closed).
|
||||
- NEVER fabricate a review verdict without an actual task call.
|
||||
- NEVER fabricate a review verdict without an actual reviewer call.
|
||||
|
||||
### Using Codex Instead of Copilot
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ Rules:
|
||||
|
||||
#### Phase B.5.1: Stop-Evaluation Gate
|
||||
|
||||
**STOP CONDITION**: If score >= 6 AND verdict ∈ {"ready", "almost"} (exact match — "not ready" does NOT qualify) → **write an acquittal line to `review-stage/ACQUITTAL_LOG.jsonl`** (see Append-Only Acquittal Receipt rules above), then stop loop, document final state.
|
||||
**STOP CONDITION**: If score >= 6 AND verdict ∈ {"ready", "almost"} (exact match — "not ready" does NOT qualify), decide to stop and continue through Phase E. **Do not write a receipt here**; Phase E is the single append site.
|
||||
|
||||
This evaluation runs AFTER Phase B.5 so the terminal-round memory is always appended to REVIEWER_MEMORY.md before exit.
|
||||
|
||||
@@ -340,9 +340,9 @@ This is the authoritative record. Do NOT truncate or paraphrase.]
|
||||
|
||||
**If score >= 6 AND verdict ∈ {"ready", "almost"}:** append an acquittal line to `review-stage/ACQUITTAL_LOG.jsonl`:
|
||||
```
|
||||
{"run_id":"<current-run_id>","round":<N>,"backend":"codex","effort":"xhigh","verdict":"<ready|almost>","score":<score>,"trace_id":"trace_<YYYYMMDD>_run<NN>","timestamp":"<ISO8601>"}
|
||||
{"run_id":"<current-run_id>","round":<N>,"backend":"codex","effort":"xhigh","verdict":"<ready|almost>","score":<score>,"trace_id":"<skill>/<YYYY-MM-DD>_run<NN>","timestamp":"<ISO8601>"}
|
||||
```
|
||||
Use `>>` (append), never `>`. The `trace_id` must reference the trace artifact written per Review Tracing protocol for this round's reviewer call.
|
||||
Use `>>` (append), never `>`. The `trace_id` must be the actual trace directory relative to `.aris/traces/` (for example `auto-review-loop/2026-07-13_run01`), not a fabricated `trace_...` identifier.
|
||||
|
||||
**Append to `findings.md`** (when `COMPACT = true`): one-line entry per key finding this round.
|
||||
|
||||
@@ -445,8 +445,8 @@ The following test cases validate the `run_id` + append-only acquittal receipt m
|
||||
|
||||
### Test 4: Append-Only Integrity
|
||||
|
||||
**Setup:** Run producing three rounds: round 1 (score=5), round 2 (score=7, "ready"), round 3 (score=8, "ready").
|
||||
**Setup:** Run 1 reaches a positive verdict, appends one receipt, and stops. Start Run 2 with a new `run_id`; it also reaches a positive verdict and appends one receipt.
|
||||
|
||||
**Action:** After the loop, inspect `ACQUITTAL_LOG.jsonl`.
|
||||
|
||||
**Expected:** File contains exactly 2 lines (round 2 and round 3), each with the same `run_id`. Lines are never overwritten or deleted.
|
||||
**Expected:** File contains exactly 2 lines with different run IDs. Run 1's line remains unchanged after Run 2 appends; a stopped loop cannot continue to a later positive round.
|
||||
|
||||
@@ -105,7 +105,7 @@ If reviewer=oracle-pro:
|
||||
|
||||
## Copilot CLI Custom Agent Profiles (`--reviewer: copilot`) — auto-review-loop only
|
||||
|
||||
The main `skills/shared-references/reviewer-routing.md` includes a copilot reviewer path scoped to `/auto-review-loop` only. It uses the documented **`copilot --agent`** subprocess with **custom agent profiles** (`aris-reviewer-openai` / `aris-reviewer-claude`) that pin specific models — cross-family is enforced by the router picking the opposite-family profile (requires `--executor-model`). Copilot is an **explicit opt-in** for `/auto-review-loop` (use `--reviewer: copilot`; Codex MCP is the default) with a **mandatory cross-family invariant** (same-family → `REVIEW_UNAVAILABLE`, never "provisional").
|
||||
The main `skills/shared-references/reviewer-routing.md` includes a copilot reviewer path scoped to `/auto-review-loop` only. It uses the documented **`copilot --agent`** subprocess with custom agent profiles (`aris-reviewer-openai` / `aris-reviewer-claude`), repeats the parsed profile model through outer `--model`, pins `--effort xhigh`, and grants only `--allow-tool=read`. Cross-family is enforced by deriving both families from model identities and picking the opposite-family profile (requires `--executor-model`). Copilot is an **explicit opt-in** for `/auto-review-loop` (use `--reviewer: copilot`; Codex MCP is the default) with a **mandatory cross-family invariant** (same-family → `REVIEW_UNAVAILABLE`, never "provisional").
|
||||
|
||||
**This routing applies to the main skills at `skills/`, not this Codex-mirror pack.** If you are reading this file from `skills/skills-codex/`, you are in the Codex CLI mirror where `spawn_agent` is the native reviewer. For Copilot CLI custom agent profile review, use the main skill set at `skills/` — see the main [`skills/shared-references/reviewer-routing.md`](../../../skills/shared-references/reviewer-routing.md#copilot-cli-custom-agent-profiles---reviewer-copilot--explicit-opt-in-for-auto-review-loop) for the full copilot contract.
|
||||
|
||||
|
||||
@@ -243,6 +243,19 @@ def test_codex_review_assurance_is_explicit_and_honest() -> None:
|
||||
assert "mcp__claude-review__review_status" in text
|
||||
|
||||
|
||||
def test_codex_auto_review_has_one_receipt_append_phase() -> None:
|
||||
text = read(CODEX_SKILLS / "auto-review-loop" / "SKILL.md")
|
||||
gate = text.split("#### Phase B.5.1: Stop-Evaluation Gate", 1)[1].split(
|
||||
"#### Phase B.6:", 1
|
||||
)[0]
|
||||
|
||||
assert "Do not write a receipt here" in gate
|
||||
assert "Phase E is the single append site" in gate
|
||||
assert '"trace_id":"<skill>/<YYYY-MM-DD>_run<NN>"' in text
|
||||
assert "fabricated `trace_...` identifier" in text
|
||||
assert 'round 3 (score=8, "ready")' not in text
|
||||
|
||||
|
||||
def test_overlay_boundaries_are_exact() -> None:
|
||||
expected_claude = {
|
||||
"auto-paper-improvement-loop",
|
||||
|
||||
+259
-17
@@ -1,6 +1,7 @@
|
||||
"""Tests for install_aris_copilot.sh and smart_update_copilot.sh."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -8,6 +9,7 @@ from pathlib import Path
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
INSTALL_SCRIPT = REPO_ROOT / "tools" / "install_aris_copilot.sh"
|
||||
UPDATE_SCRIPT = REPO_ROOT / "tools" / "smart_update_copilot.sh"
|
||||
TRACE_SCRIPT = REPO_ROOT / "tools" / "save_trace.sh"
|
||||
|
||||
|
||||
def run(
|
||||
@@ -392,7 +394,7 @@ def test_smart_update_copilot_copy_install(tmp_path: Path) -> None:
|
||||
]
|
||||
)
|
||||
assert dry_run.returncode == 0
|
||||
assert "Run with --apply" in dry_run.stdout
|
||||
assert "Dry run complete. Use --apply to apply these changes." in dry_run.stdout
|
||||
|
||||
# Apply
|
||||
result = run(
|
||||
@@ -526,6 +528,37 @@ def test_install_copilot_deploys_agents(tmp_path: Path) -> None:
|
||||
assert (agents_dir / "aris-reviewer-claude.agent.md").resolve() == (repo_agents / "aris-reviewer-claude.agent.md")
|
||||
|
||||
|
||||
def test_reviewer_profiles_use_supported_frontmatter_and_explicit_models() -> None:
|
||||
for name, model in (
|
||||
("aris-reviewer-openai.agent.md", "gpt-5.4"),
|
||||
("aris-reviewer-claude.agent.md", "claude-sonnet-4.5"),
|
||||
):
|
||||
text = (REPO_ROOT / ".github" / "agents" / name).read_text()
|
||||
assert f"model: {model}" in text
|
||||
assert "model_family:" not in text
|
||||
assert "tools: read" in text
|
||||
|
||||
|
||||
def test_install_copilot_skips_symlinked_upstream_agents_directory(tmp_path: Path) -> None:
|
||||
repo = make_minimal_aris_repo(tmp_path)
|
||||
external = tmp_path / "external-agents"
|
||||
external.mkdir()
|
||||
(external / "aris-reviewer-openai.agent.md").write_text("---\nmodel: gpt-5.4\n---\n")
|
||||
(repo / ".github").mkdir()
|
||||
(repo / ".github" / "agents").symlink_to(external, target_is_directory=True)
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
|
||||
result = run(
|
||||
["bash", str(INSTALL_SCRIPT), str(project), "--aris-repo", str(repo), "--quiet"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "skipping symlinked upstream agents directory" in result.stderr
|
||||
assert not (project / ".github" / "agents" / "aris-reviewer-openai.agent.md").exists()
|
||||
|
||||
|
||||
def test_smart_update_copilot_deploys_agents(tmp_path: Path) -> None:
|
||||
"""smart_update_copilot.sh deploys .github/agents/ in copy-mode."""
|
||||
upstream = tmp_path / "upstream"
|
||||
@@ -561,6 +594,97 @@ def test_smart_update_copilot_deploys_agents(tmp_path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _make_copy_update_with_agent(tmp_path: Path) -> tuple[Path, Path, str]:
|
||||
upstream = tmp_path / "upstream"
|
||||
make_skill(upstream / "alpha", "---\nname: alpha\n---\n# alpha\n")
|
||||
upstream_agents = upstream.parent / ".github" / "agents"
|
||||
upstream_agents.mkdir(parents=True, exist_ok=True)
|
||||
content = "---\nmodel: gpt-5.4\n---\n# guarded-agent\n"
|
||||
(upstream_agents / "aris-reviewer-openai.agent.md").write_text(content)
|
||||
local = tmp_path / "local"
|
||||
local.mkdir()
|
||||
return upstream, local, content
|
||||
|
||||
|
||||
def test_smart_update_refuses_existing_agent_symlink(tmp_path: Path) -> None:
|
||||
"""An agent file symlink must never redirect an update outside the target."""
|
||||
upstream, local, _ = _make_copy_update_with_agent(tmp_path)
|
||||
agents = local.parent / "agents"
|
||||
agents.mkdir()
|
||||
external = tmp_path / "external.agent.md"
|
||||
external.write_text("do-not-touch\n")
|
||||
link = agents / "aris-reviewer-openai.agent.md"
|
||||
link.symlink_to(external)
|
||||
|
||||
result = run(
|
||||
["bash", str(UPDATE_SCRIPT), "--upstream", str(upstream), "--local", str(local), "--apply"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "refusing symlinked agent destination" in result.stderr
|
||||
assert link.is_symlink()
|
||||
assert external.read_text() == "do-not-touch\n"
|
||||
|
||||
|
||||
def test_smart_update_refuses_broken_agent_symlink(tmp_path: Path) -> None:
|
||||
"""A broken destination symlink must not be followed or repaired by copying."""
|
||||
upstream, local, _ = _make_copy_update_with_agent(tmp_path)
|
||||
agents = local.parent / "agents"
|
||||
agents.mkdir()
|
||||
external = tmp_path / "missing-external.agent.md"
|
||||
link = agents / "aris-reviewer-openai.agent.md"
|
||||
link.symlink_to(external)
|
||||
|
||||
result = run(
|
||||
["bash", str(UPDATE_SCRIPT), "--upstream", str(upstream), "--local", str(local), "--apply"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "refusing symlinked agent destination" in result.stderr
|
||||
assert link.is_symlink()
|
||||
assert not external.exists()
|
||||
|
||||
|
||||
def test_smart_update_refuses_symlinked_agents_directory(tmp_path: Path) -> None:
|
||||
"""A symlinked agents directory must not redirect profile deployment."""
|
||||
upstream, local, _ = _make_copy_update_with_agent(tmp_path)
|
||||
external_dir = tmp_path / "external-agents"
|
||||
external_dir.mkdir()
|
||||
(local.parent / "agents").symlink_to(external_dir, target_is_directory=True)
|
||||
|
||||
result = run(
|
||||
["bash", str(UPDATE_SCRIPT), "--upstream", str(upstream), "--local", str(local), "--apply"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "refusing symlinked agent destination path" in result.stderr
|
||||
assert not (external_dir / "aris-reviewer-openai.agent.md").exists()
|
||||
|
||||
|
||||
def test_smart_update_refuses_symlinked_upstream_agents_directory(tmp_path: Path) -> None:
|
||||
upstream = tmp_path / "upstream"
|
||||
make_skill(upstream / "alpha", "---\nname: alpha\n---\n# alpha\n")
|
||||
external = tmp_path / "external-upstream-agents"
|
||||
external.mkdir()
|
||||
(external / "aris-reviewer-openai.agent.md").write_text("---\nmodel: gpt-5.4\n---\n")
|
||||
(tmp_path / ".github").mkdir()
|
||||
(tmp_path / ".github" / "agents").symlink_to(external, target_is_directory=True)
|
||||
local = tmp_path / "local"
|
||||
local.mkdir()
|
||||
|
||||
result = run(
|
||||
["bash", str(UPDATE_SCRIPT), "--upstream", str(upstream), "--local", str(local), "--apply"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode != 0
|
||||
assert "refusing symlinked upstream agents directory" in result.stderr
|
||||
assert not (local.parent / "agents").exists()
|
||||
|
||||
|
||||
def test_install_copilot_reconcile_agents(tmp_path: Path) -> None:
|
||||
"""Reconcile picks up new agents and removes deleted ones."""
|
||||
repo = make_minimal_aris_repo(tmp_path)
|
||||
@@ -674,6 +798,33 @@ def test_routing_fail_closed_unknown_executor_family(tmp_path: Path) -> None:
|
||||
assert "unknown" in skill_text
|
||||
|
||||
|
||||
def test_copilot_prompt_templates_keep_untrusted_text_out_of_heredocs() -> None:
|
||||
"""Memory and rebuttal artifacts are concatenated as data, not shell source."""
|
||||
skill_text = (REPO_ROOT / "skills" / "auto-review-loop" / "SKILL.md").read_text()
|
||||
routing_text = (REPO_ROOT / "skills" / "shared-references" / "reviewer-routing.md").read_text()
|
||||
|
||||
for text in (skill_text, routing_text):
|
||||
assert "PROMPT_EOF" not in text
|
||||
assert 'reviewer_prompt_$$' not in text
|
||||
assert 'PROMPTFILE="$(mktemp)" || {' in text
|
||||
assert '--model "$REVIEWER_MODEL"' in text
|
||||
assert "--effort xhigh" in text
|
||||
assert "--allow-tool=read" in text
|
||||
assert 'cat -- "$MEMORY_FILE"' in skill_text
|
||||
assert 'cat -- "$REBUTTAL_FILE"' in skill_text
|
||||
assert 'cat -- "$MEMORY_FILE"' in routing_text
|
||||
|
||||
|
||||
def test_stop_gate_uses_current_round_backend_and_model_derived_families() -> None:
|
||||
skill_text = (REPO_ROOT / "skills" / "auto-review-loop" / "SKILL.md").read_text()
|
||||
|
||||
assert "branch by `round_backend`" in skill_text
|
||||
assert "never by the forward-looking `REVIEWER_BACKEND`" in skill_text
|
||||
assert 'executor_model' in skill_text
|
||||
assert 'reviewer_model' in skill_text
|
||||
assert "Never trust receipt family strings" in skill_text
|
||||
|
||||
|
||||
# --- Legacy-state resume tests ---
|
||||
|
||||
def test_legacy_review_state_missing_backend_defaults_to_codex(tmp_path: Path) -> None:
|
||||
@@ -741,25 +892,116 @@ def test_save_trace_executor_field_not_hardcoded(tmp_path: Path) -> None:
|
||||
assert 'ST_EXECUTOR' in script_text or 'EXECUTOR' in script_text
|
||||
|
||||
|
||||
def test_save_trace_effort_unpinned_for_copilot(tmp_path: Path) -> None:
|
||||
"""When backend is copilot, effort_unpinned is true in traces."""
|
||||
trace_script = REPO_ROOT / "tools" / "save_trace.sh"
|
||||
script_text = trace_script.read_text()
|
||||
|
||||
assert "effort_unpinned" in script_text
|
||||
assert "copilot" in script_text
|
||||
def _save_trace_request(tmp_path: Path, *extra: str) -> tuple[dict, dict, dict]:
|
||||
result = run(
|
||||
[
|
||||
"bash",
|
||||
str(TRACE_SCRIPT),
|
||||
"--skill",
|
||||
"auto-review-loop",
|
||||
"--purpose",
|
||||
"round-review",
|
||||
"--prompt",
|
||||
"review this",
|
||||
"--response",
|
||||
"ready",
|
||||
*extra,
|
||||
],
|
||||
cwd=tmp_path,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
run_dir = next((tmp_path / ".aris" / "traces" / "auto-review-loop").iterdir())
|
||||
request = json.loads(next(run_dir.glob("*.request.json")).read_text())
|
||||
meta = json.loads(next(run_dir.glob("*.meta.json")).read_text())
|
||||
run_meta = json.loads((run_dir / "run.meta.json").read_text())
|
||||
return request, meta, run_meta
|
||||
|
||||
|
||||
def test_save_trace_independence_verified_derived(tmp_path: Path) -> None:
|
||||
"""independence_verified is derived, not blindly trusted from caller input."""
|
||||
trace_script = REPO_ROOT / "tools" / "save_trace.sh"
|
||||
script_text = trace_script.read_text()
|
||||
def test_save_trace_copilot_xhigh_is_pinned(tmp_path: Path) -> None:
|
||||
request, meta, _ = _save_trace_request(
|
||||
tmp_path,
|
||||
"--backend", "copilot",
|
||||
"--model", "gpt-5.4",
|
||||
"--effort", "xhigh",
|
||||
"--executor-model", "claude-sonnet-4.5",
|
||||
"--requested-reviewer-model", "gpt-5.4",
|
||||
)
|
||||
|
||||
# Must contain the "unverified" fallback logic
|
||||
assert '"unverified"' in script_text
|
||||
# Must derive from families, not just pass through
|
||||
assert "ST_EXECUTOR_FAMILY" in script_text
|
||||
assert "ST_REVIEWER_FAMILY" in script_text
|
||||
assert request["effort"] == "xhigh"
|
||||
assert request["effort_unpinned"] is False
|
||||
assert meta["effort_unpinned"] is False
|
||||
|
||||
|
||||
def test_save_trace_unpinned_copilot_call_remains_ineligible(tmp_path: Path) -> None:
|
||||
request, _, _ = _save_trace_request(
|
||||
tmp_path,
|
||||
"--backend", "copilot",
|
||||
"--model", "gpt-5.4",
|
||||
"--effort", "high",
|
||||
"--executor-model", "claude-sonnet-4.5",
|
||||
"--requested-reviewer-model", "gpt-5.4",
|
||||
)
|
||||
|
||||
assert request["effort_unpinned"] is True
|
||||
|
||||
|
||||
def test_save_trace_rejects_spoofed_family_and_independence(tmp_path: Path) -> None:
|
||||
"""Same-family models stay same-family despite contradictory caller labels."""
|
||||
request, meta, run_meta = _save_trace_request(
|
||||
tmp_path,
|
||||
"--backend", "copilot",
|
||||
"--model", "gpt-5.4",
|
||||
"--effort", "xhigh",
|
||||
"--executor-model", "gpt-5.4",
|
||||
"--executor-family", "anthropic",
|
||||
"--requested-reviewer-model", "gpt-5.4",
|
||||
"--reviewer-family", "google",
|
||||
"--independence-verified", "true",
|
||||
)
|
||||
|
||||
assert request["executor_family"] == "openai"
|
||||
assert request["reviewer_family"] == "openai"
|
||||
assert request["independence_verified"] is False
|
||||
assert meta["model_family"] == "openai"
|
||||
assert meta["independence_verified"] is False
|
||||
assert run_meta["executor_family"] == "openai"
|
||||
assert run_meta["reviewer_family"] == "openai"
|
||||
|
||||
|
||||
def test_save_trace_derives_cross_family_from_models(tmp_path: Path) -> None:
|
||||
request, _, _ = _save_trace_request(
|
||||
tmp_path,
|
||||
"--backend", "copilot",
|
||||
"--model", "gpt-5.4",
|
||||
"--effort", "xhigh",
|
||||
"--executor-model", "claude-sonnet-4.5",
|
||||
"--executor-family", "openai",
|
||||
"--requested-reviewer-model", "gpt-5.4",
|
||||
"--reviewer-family", "anthropic",
|
||||
"--independence-verified", "false",
|
||||
)
|
||||
|
||||
assert request["executor_family"] == "anthropic"
|
||||
assert request["reviewer_family"] == "openai"
|
||||
assert request["independence_verified"] is True
|
||||
|
||||
|
||||
def test_save_trace_unknown_model_is_unverified(tmp_path: Path) -> None:
|
||||
request, _, _ = _save_trace_request(
|
||||
tmp_path,
|
||||
"--backend", "copilot",
|
||||
"--model", "gpt-5.4",
|
||||
"--effort", "xhigh",
|
||||
"--executor-model", "mystery-model",
|
||||
"--requested-reviewer-model", "gpt-5.4",
|
||||
"--executor-family", "anthropic",
|
||||
"--independence-verified", "true",
|
||||
)
|
||||
|
||||
assert request["executor_family"] == "unknown"
|
||||
assert request["reviewer_family"] == "openai"
|
||||
assert request["independence_verified"] == "unverified"
|
||||
|
||||
|
||||
def test_review_tracing_doc_copilot_model_is_gpt5_4(tmp_path: Path) -> None:
|
||||
|
||||
@@ -8,6 +8,7 @@ Covers:
|
||||
a different target / as a non-symlink path
|
||||
uninstall: removes the managed symlink
|
||||
uninstall: preserves non-managed `.aris/tools` (different target / real dir)
|
||||
agent profiles: ownership sidecar removes only links this installer created
|
||||
dry-run: prints planned action without writing anything
|
||||
"""
|
||||
import os
|
||||
@@ -158,6 +159,38 @@ class InstallTest(unittest.TestCase):
|
||||
self.assertTrue(link.is_symlink(), "user-created symlink must be preserved")
|
||||
self.assertEqual(os.readlink(link), str(elsewhere))
|
||||
|
||||
def test_uninstall_removes_installer_created_agent_profiles(self):
|
||||
self._run()
|
||||
agent = self.project / ".github" / "agents" / "aris-reviewer-openai.agent.md"
|
||||
ownership = self.project / ".aris" / "installed-agent-profiles.txt"
|
||||
self.assertTrue(agent.is_symlink())
|
||||
self.assertIn(agent.name, ownership.read_text().splitlines())
|
||||
|
||||
result = self._run("--uninstall")
|
||||
|
||||
self.assertEqual(result.returncode, 0, msg=result.stderr)
|
||||
self.assertFalse(agent.exists())
|
||||
self.assertFalse(agent.is_symlink())
|
||||
|
||||
def test_uninstall_preserves_identical_preexisting_agent_symlink(self):
|
||||
agents_dir = self.project / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
agent = agents_dir / "aris-reviewer-openai.agent.md"
|
||||
expected = REPO_ROOT / ".github" / "agents" / agent.name
|
||||
os.symlink(str(expected), str(agent))
|
||||
|
||||
install = self._run()
|
||||
self.assertEqual(install.returncode, 0, msg=install.stderr)
|
||||
ownership = self.project / ".aris" / "installed-agent-profiles.txt"
|
||||
if ownership.exists():
|
||||
self.assertNotIn(agent.name, ownership.read_text().splitlines())
|
||||
|
||||
uninstall = self._run("--uninstall")
|
||||
|
||||
self.assertEqual(uninstall.returncode, 0, msg=uninstall.stderr)
|
||||
self.assertTrue(agent.is_symlink(), "pre-existing exact link is user-owned")
|
||||
self.assertEqual(os.readlink(agent), str(expected))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -273,6 +273,45 @@ def test_browser_mode_http():
|
||||
srv._current_session = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Strict cross-family identity gate
|
||||
# ============================================================
|
||||
|
||||
def test_strict_manual_reviewer_identity_gate():
|
||||
strict_openai = {
|
||||
"require_reviewer_model": True,
|
||||
"executor_model": "gpt-5.4",
|
||||
}
|
||||
|
||||
assert srv.validate_reviewer_identity(
|
||||
"Reviewer-Model: claude-sonnet-4.5\n\nScore: 7/10", strict_openai
|
||||
) is None
|
||||
assert "must begin" in srv.validate_reviewer_identity(
|
||||
"Score: 7/10", strict_openai
|
||||
)
|
||||
assert "different model family" in srv.validate_reviewer_identity(
|
||||
"Reviewer-Model: gpt-5.6-sol\n\nScore: 7/10", strict_openai
|
||||
)
|
||||
assert "model family" in srv.validate_reviewer_identity(
|
||||
"Reviewer-Model: mystery-model\n\nScore: 7/10", strict_openai
|
||||
)
|
||||
|
||||
# Legacy/manual uses that do not request a verdict-bearing identity gate
|
||||
# retain their existing transport behavior.
|
||||
assert srv.validate_reviewer_identity("Score: 7/10", {}) is None
|
||||
|
||||
|
||||
def test_file_mode_warning_uses_actual_executor_family():
|
||||
warning = srv.file_mode_warning({
|
||||
"require_reviewer_model": True,
|
||||
"executor_model": "gpt-5.4",
|
||||
})
|
||||
assert "gpt-5.4" in warning
|
||||
assert "openai" in warning
|
||||
assert "Reviewer-Model: <exact-model-id>" in warning
|
||||
assert "non-Claude" not in warning
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Test 6: File mode — prompt + response + cross-model warning
|
||||
# ============================================================
|
||||
@@ -323,8 +362,8 @@ def test_file_mode():
|
||||
|
||||
content = prompt_path.read_text(encoding="utf-8")
|
||||
assert "Cross-Model Warning" in content, "missing cross-model warning"
|
||||
assert "do NOT paste this prompt into any Claude product" in content, \
|
||||
"missing Claude-specific warning"
|
||||
assert "DIFFERENT model family" in content, \
|
||||
"missing executor-agnostic cross-family warning"
|
||||
assert "File mode test prompt" in content, f"wrong content: {content[:200]}"
|
||||
|
||||
# Simulate user writing response
|
||||
|
||||
+41
-9
@@ -51,6 +51,8 @@
|
||||
# path or differently-targeted symlink at `.aris/tools` is left alone.
|
||||
# S12 Temp files live in the same directory as the destination.
|
||||
# S13 Skill names must match ^[A-Za-z0-9][A-Za-z0-9._-]*$ (slug regex).
|
||||
# S14 Copilot agent links are removed only when listed in the dedicated
|
||||
# installed-agent-profiles.txt ownership sidecar.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -58,6 +60,7 @@ set -euo pipefail
|
||||
MANIFEST_VERSION="1"
|
||||
MANIFEST_NAME="installed-skills.txt"
|
||||
MANIFEST_PREV_NAME="installed-skills.txt.prev"
|
||||
AGENT_MANIFEST_NAME="installed-agent-profiles.txt"
|
||||
ARIS_DIR_NAME=".aris"
|
||||
LOCK_DIR_NAME=".install.lock.d"
|
||||
SKILLS_REL=".claude/skills"
|
||||
@@ -328,6 +331,7 @@ PROJECT_SKILLS_DIR="$PROJECT_PATH/$SKILLS_REL"
|
||||
PROJECT_ARIS_DIR="$PROJECT_PATH/$ARIS_DIR_NAME"
|
||||
MANIFEST_PATH="$PROJECT_ARIS_DIR/$MANIFEST_NAME"
|
||||
MANIFEST_PREV="$PROJECT_ARIS_DIR/$MANIFEST_PREV_NAME"
|
||||
AGENT_MANIFEST_PATH="$PROJECT_ARIS_DIR/$AGENT_MANIFEST_NAME"
|
||||
LOCK_DIR="$PROJECT_ARIS_DIR/$LOCK_DIR_NAME"
|
||||
DOC_FILE="$PROJECT_PATH/$DOC_FILE_NAME"
|
||||
|
||||
@@ -675,9 +679,28 @@ remove_tools_symlink() {
|
||||
# user-created files/dirs/symlinks at the target path are left alone.
|
||||
# Pure-additive — existing users who don't rerun the installer never see
|
||||
# this. Idempotent across re-runs.
|
||||
record_managed_agent_profile() {
|
||||
local name="$1" tmp="$AGENT_MANIFEST_PATH.tmp.$$" unsorted="$AGENT_MANIFEST_PATH.unsorted.$$"
|
||||
mkdir -p "$PROJECT_ARIS_DIR"
|
||||
if [[ -L "$AGENT_MANIFEST_PATH" ]]; then
|
||||
warn "$AGENT_MANIFEST_PATH is a symlink; refusing to record agent ownership"
|
||||
return 1
|
||||
fi
|
||||
if [[ -f "$AGENT_MANIFEST_PATH" ]]; then
|
||||
cp "$AGENT_MANIFEST_PATH" "$unsorted" || { rm -f "$unsorted" "$tmp"; return 1; }
|
||||
else
|
||||
: > "$unsorted" || return 1
|
||||
fi
|
||||
printf '%s\n' "$name" >> "$unsorted" || { rm -f "$unsorted" "$tmp"; return 1; }
|
||||
sort -u "$unsorted" > "$tmp" || { rm -f "$unsorted" "$tmp"; return 1; }
|
||||
rm -f "$unsorted"
|
||||
mv -f "$tmp" "$AGENT_MANIFEST_PATH"
|
||||
}
|
||||
|
||||
ensure_agent_profiles() {
|
||||
local src_dir="$ARIS_REPO/$AGENT_PROFILES_SRC"
|
||||
[[ -d "$src_dir" ]] || return 0 # no profiles to deploy
|
||||
[[ ! -L "$src_dir" ]] || { warn "skipping symlinked upstream agents directory: $src_dir"; return 0; }
|
||||
local target_dir="$PROJECT_PATH/.github/agents"
|
||||
local deployed=0 name src target
|
||||
|
||||
@@ -710,6 +733,10 @@ ensure_agent_profiles() {
|
||||
else
|
||||
mkdir -p "$target_dir"
|
||||
ln -s "$src" "$target"
|
||||
if ! record_managed_agent_profile "$name"; then
|
||||
rm -f "$target"
|
||||
die "could not record ownership for .github/agents/$name"
|
||||
fi
|
||||
deployed=$((deployed + 1))
|
||||
fi
|
||||
done
|
||||
@@ -719,22 +746,24 @@ ensure_agent_profiles() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Counterpart for uninstall: only remove agent profile symlinks whose
|
||||
# target is inside <aris-repo>/.github/agents/. User-created files or
|
||||
# differently-targeted symlinks are left alone.
|
||||
# Counterpart for uninstall: only remove agent profile symlinks explicitly
|
||||
# recorded when this installer created them. An identical pre-existing link is
|
||||
# user-owned and must survive uninstall.
|
||||
remove_agent_profiles() {
|
||||
local target_dir="$PROJECT_PATH/.github/agents"
|
||||
[[ -d "$target_dir" ]] || return 0
|
||||
[[ -d "$target_dir" || -L "$target_dir" ]] || return 0
|
||||
[[ -f "$AGENT_MANIFEST_PATH" ]] || return 0
|
||||
[[ ! -L "$AGENT_MANIFEST_PATH" ]] || { warn "$AGENT_MANIFEST_PATH is a symlink; refusing agent cleanup"; return 0; }
|
||||
local src_dir="$ARIS_REPO/$AGENT_PROFILES_SRC"
|
||||
[[ -d "$src_dir" ]] || return 0
|
||||
local name target removed=0
|
||||
|
||||
for target in "$target_dir"/*.agent.md; do
|
||||
while IFS= read -r name; do
|
||||
[[ "$name" =~ $SAFE_NAME_REGEX && "$name" == *.agent.md ]] || { warn "invalid agent manifest entry: $name"; continue; }
|
||||
target="$target_dir/$name"
|
||||
[[ -L "$target" ]] || continue
|
||||
name="$(basename "$target")"
|
||||
local cur; cur="$(read_link_target "$target")"
|
||||
[[ "$cur" != /* ]] && cur="$(canonicalize "$(dirname "$target")/$cur")"
|
||||
# Only remove if target points into our source dir
|
||||
# Revalidate the exact source target before mutation.
|
||||
if [[ "$cur" == "$src_dir/$name" ]]; then
|
||||
if $DRY_RUN; then
|
||||
log " (dry-run) rm $target"
|
||||
@@ -743,13 +772,16 @@ remove_agent_profiles() {
|
||||
removed=$((removed + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
done < "$AGENT_MANIFEST_PATH"
|
||||
|
||||
if ! $DRY_RUN && (( removed > 0 )); then
|
||||
log " - .github/agents/ ($removed copilot profile(s) removed)"
|
||||
# Remove directory if empty after cleanup
|
||||
rmdir "$target_dir" 2>/dev/null || true
|
||||
fi
|
||||
if ! $DRY_RUN; then
|
||||
rm -f "$AGENT_MANIFEST_PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
commit_manifest() {
|
||||
|
||||
@@ -193,17 +193,21 @@ build_upstream_inventory() {
|
||||
# Include agent profiles from .github/agents/
|
||||
agents_dir="$repo/.github/agents"
|
||||
if [[ -d "$agents_dir" ]]; then
|
||||
for f in "$agents_dir"/*.md; do
|
||||
[[ -f "$f" ]] || continue
|
||||
# Resolve symlink and verify it's within the expected directory
|
||||
local resolved; resolved="$(canonicalize "$f")"
|
||||
local agents_canon; agents_canon="$(canonicalize "$agents_dir")"
|
||||
[[ "$resolved" == "$agents_canon"/* ]] || { warn "skipping external symlink: $f -> $resolved"; continue; }
|
||||
agent_name="$(basename "$f")"
|
||||
base_name="${agent_name%.agent.md}"
|
||||
is_safe_name "$base_name" || { warn "skipping unsafe agent name: $agent_name"; continue; }
|
||||
printf "agent|%s|.github/agents/%s\n" "$agent_name" "$agent_name" >> "$out"
|
||||
done
|
||||
if [[ -L "$agents_dir" ]]; then
|
||||
warn "skipping symlinked upstream agents directory: $agents_dir"
|
||||
else
|
||||
for f in "$agents_dir"/*.agent.md; do
|
||||
[[ -f "$f" ]] || continue
|
||||
# Resolve symlink and verify it's within the expected directory
|
||||
local resolved; resolved="$(canonicalize "$f")"
|
||||
local agents_canon; agents_canon="$(canonicalize "$agents_dir")"
|
||||
[[ "$resolved" == "$agents_canon"/* ]] || { warn "skipping external symlink: $f -> $resolved"; continue; }
|
||||
agent_name="$(basename "$f")"
|
||||
base_name="${agent_name%.agent.md}"
|
||||
is_safe_name "$base_name" || { warn "skipping unsafe agent name: $agent_name"; continue; }
|
||||
printf "agent|%s|.github/agents/%s\n" "$agent_name" "$agent_name" >> "$out"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
[[ -s "$out" ]] || die "upstream inventory empty"
|
||||
|
||||
+62
-4
@@ -78,6 +78,59 @@ if [[ "$TRACE_MODE" == "off" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Derive provenance from model identities, never from caller-supplied family or
|
||||
# independence labels. The legacy family/independence flags remain accepted so
|
||||
# older callers do not break, but they are only consistency hints.
|
||||
derive_model_family() {
|
||||
ST_MODEL_NAME="$1" python3 -c '
|
||||
import os, re
|
||||
|
||||
name = (os.environ.get("ST_MODEL_NAME") or "").strip().lower()
|
||||
families = set()
|
||||
if re.search(r"(^|[^a-z0-9])(gpt|chatgpt|codex|oracle|o1|o3|o4)([^a-z0-9]|$)", name):
|
||||
families.add("openai")
|
||||
if re.search(r"(^|[^a-z0-9])(claude|sonnet|opus|haiku|anthropic)([^a-z0-9]|$)", name):
|
||||
families.add("anthropic")
|
||||
if re.search(r"(^|[^a-z0-9])(gemini|google)([^a-z0-9]|$)", name):
|
||||
families.add("google")
|
||||
print(next(iter(families)) if len(families) == 1 else "unknown")
|
||||
'
|
||||
}
|
||||
|
||||
lowercase() {
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
CALLER_EXECUTOR_FAMILY="$EXECUTOR_FAMILY"
|
||||
CALLER_REVIEWER_FAMILY="$REVIEWER_FAMILY"
|
||||
CALLER_INDEPENDENCE_VERIFIED="$INDEPENDENCE_VERIFIED"
|
||||
|
||||
EFFECTIVE_REVIEWER_MODEL="$REPORTED_REVIEWER_MODEL"
|
||||
case "$(lowercase "$EFFECTIVE_REVIEWER_MODEL")" in
|
||||
""|unknown|unavailable|none|null) EFFECTIVE_REVIEWER_MODEL="${REQUESTED_REVIEWER_MODEL:-$MODEL}" ;;
|
||||
esac
|
||||
|
||||
EXECUTOR_FAMILY="$(derive_model_family "$EXECUTOR_MODEL")"
|
||||
REVIEWER_FAMILY="$(derive_model_family "$EFFECTIVE_REVIEWER_MODEL")"
|
||||
|
||||
if [[ -n "$CALLER_EXECUTOR_FAMILY" && "$(lowercase "$CALLER_EXECUTOR_FAMILY")" != "$EXECUTOR_FAMILY" ]]; then
|
||||
echo "warning: ignoring executor family '$CALLER_EXECUTOR_FAMILY'; model '$EXECUTOR_MODEL' derives as '$EXECUTOR_FAMILY'" >&2
|
||||
fi
|
||||
if [[ -n "$CALLER_REVIEWER_FAMILY" && "$(lowercase "$CALLER_REVIEWER_FAMILY")" != "$REVIEWER_FAMILY" ]]; then
|
||||
echo "warning: ignoring reviewer family '$CALLER_REVIEWER_FAMILY'; model '$EFFECTIVE_REVIEWER_MODEL' derives as '$REVIEWER_FAMILY'" >&2
|
||||
fi
|
||||
|
||||
if [[ "$EXECUTOR_FAMILY" == "unknown" || "$REVIEWER_FAMILY" == "unknown" ]]; then
|
||||
INDEPENDENCE_VERIFIED="unverified"
|
||||
elif [[ "$EXECUTOR_FAMILY" != "$REVIEWER_FAMILY" ]]; then
|
||||
INDEPENDENCE_VERIFIED="true"
|
||||
else
|
||||
INDEPENDENCE_VERIFIED="false"
|
||||
fi
|
||||
if [[ -n "$CALLER_INDEPENDENCE_VERIFIED" && "$(lowercase "$CALLER_INDEPENDENCE_VERIFIED")" != "$INDEPENDENCE_VERIFIED" ]]; then
|
||||
echo "warning: ignoring independence value '$CALLER_INDEPENDENCE_VERIFIED'; model-derived value is '$INDEPENDENCE_VERIFIED'" >&2
|
||||
fi
|
||||
|
||||
# --- Read from files if provided ---
|
||||
if [[ -n "$PROMPT_FILE" && -f "$PROMPT_FILE" ]]; then
|
||||
PROMPT=$(cat "$PROMPT_FILE")
|
||||
@@ -148,7 +201,8 @@ if [[ "$TRACE_MODE" == "full" ]]; then
|
||||
ST_OUT="${RUN_DIR}/${CALL_PREFIX}-${PURPOSE}.request.json" python3 -c '
|
||||
import json, os, sys
|
||||
e = os.environ
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot")
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot" and
|
||||
(e.get("ST_EFFORT") or "").lower() != "xhigh")
|
||||
# Validate both families against the known set before comparing.
|
||||
# Unknown or unset families produce "unverified" — the schema’s
|
||||
# "both known" rule means we must not guess independence.
|
||||
@@ -200,7 +254,8 @@ else
|
||||
ST_OUT="${RUN_DIR}/${CALL_PREFIX}-${PURPOSE}.request.json" python3 -c '
|
||||
import json, os
|
||||
e = os.environ
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot")
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot" and
|
||||
(e.get("ST_EFFORT") or "").lower() != "xhigh")
|
||||
# Validate both families against the known set before comparing.
|
||||
# Unknown or unset families produce "unverified" — the schema’s
|
||||
# "both known" rule means we must not guess independence.
|
||||
@@ -250,7 +305,8 @@ ST_MEMORY_HASH="$MEMORY_HASH" \
|
||||
ST_OUT="${RUN_DIR}/${CALL_PREFIX}-${PURPOSE}.meta.json" python3 -c '
|
||||
import json, os
|
||||
e = os.environ
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot")
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot" and
|
||||
(e.get("ST_EFFORT") or "").lower() != "xhigh")
|
||||
# Validate both families against the known set before comparing.
|
||||
# Unknown or unset families produce "unverified" — the schema’s
|
||||
# "both known" rule means we must not guess independence.
|
||||
@@ -289,12 +345,14 @@ if [[ -d ".aris/meta" ]]; then
|
||||
ST_SKILL="$SKILL" ST_PURPOSE="$PURPOSE" ST_THREAD="$THREAD_ID" \
|
||||
ST_TRACE="${RUN_DIR}/" ST_STATUS="$STATUS" ST_EVENTS="$EVENTS_FILE" \
|
||||
ST_BACKEND="$BACKEND" ST_TOOL="$TOOL" \
|
||||
ST_EFFORT="$EFFORT" \
|
||||
ST_EXECUTOR="${EXECUTOR:-claude-code}" ST_EXECUTOR_FAMILY="$EXECUTOR_FAMILY" ST_REVIEWER_FAMILY="$REVIEWER_FAMILY" \
|
||||
ST_MEMORY_HASH="$MEMORY_HASH" \
|
||||
ST_INDEPENDENCE_VERIFIED="$INDEPENDENCE_VERIFIED" python3 -c '
|
||||
import json, os
|
||||
e = os.environ
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot")
|
||||
effort_unpinned = (e.get("ST_BACKEND") == "copilot" and
|
||||
(e.get("ST_EFFORT") or "").lower() != "xhigh")
|
||||
# Validate both families against the known set before comparing.
|
||||
# Unknown or unset families produce "unverified" — the schema’s
|
||||
# "both known" rule means we must not guess independence.
|
||||
|
||||
@@ -90,7 +90,7 @@ resolve_local() {
|
||||
# Resolve the agents directory corresponding to the local skills directory.
|
||||
resolve_local_agents() {
|
||||
if $HAS_CUSTOM_LOCAL; then
|
||||
echo "$CUSTOM_LOCAL/../agents"
|
||||
echo "$(dirname "$CUSTOM_LOCAL")/agents"
|
||||
elif [[ "$MODE" == "project" ]]; then
|
||||
local p
|
||||
p="$(cd "$PROJECT_PATH" 2>/dev/null && pwd)" || die "project path not found: $PROJECT_PATH"
|
||||
@@ -100,6 +100,50 @@ resolve_local_agents() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Refuse any existing symlink in a destination path. A direct file check is
|
||||
# not enough: `.github/agents` (or one of its parents) could itself redirect
|
||||
# writes outside the selected project.
|
||||
refuse_symlink_components() {
|
||||
local probe="$1" stop="$2" parent
|
||||
while :; do
|
||||
[[ ! -L "$probe" ]] || die "refusing symlinked agent destination path: $probe"
|
||||
[[ "$probe" != "$stop" ]] || break
|
||||
parent="$(dirname "$probe")"
|
||||
[[ "$parent" != "$probe" ]] || die "agent destination escapes safety root: $1"
|
||||
probe="$parent"
|
||||
done
|
||||
}
|
||||
|
||||
# Resolve an existing source path portably (GNU/Linux, macOS, then Python).
|
||||
canonicalize() {
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
realpath "$1"
|
||||
elif readlink -f "$1" >/dev/null 2>&1; then
|
||||
readlink -f "$1"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import os, sys; print(os.path.realpath(sys.argv[1]))' "$1"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Copy through a same-directory temporary file and rename it into place. The
|
||||
# rename replaces a concurrently-created symlink instead of following it.
|
||||
copy_agent_atomically() {
|
||||
local source="$1" target="$2" target_dir tmp
|
||||
[[ ! -L "$target" ]] || die "refusing symlinked agent destination: $target"
|
||||
target_dir="$(dirname "$target")"
|
||||
refuse_symlink_components "$target_dir" "$LOCAL_AGENTS_ROOT"
|
||||
tmp="$(mktemp "$target_dir/.aris-agent.XXXXXX")" || die "cannot create temporary agent file in $target_dir"
|
||||
if ! cp "$source" "$tmp"; then
|
||||
rm -f "$tmp"
|
||||
die "cannot copy agent profile: $source"
|
||||
fi
|
||||
chmod 0644 "$tmp"
|
||||
[[ ! -L "$target" ]] || { rm -f "$tmp"; die "agent destination became a symlink: $target"; }
|
||||
mv -f "$tmp" "$target"
|
||||
}
|
||||
|
||||
# Compute SHA-256 of a file (portable across GNU/BSD)
|
||||
file_sha256() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
@@ -258,26 +302,31 @@ fi
|
||||
# changes aren't silently skipped — #361 isolation finding P1) ───
|
||||
UPSTREAM_AGENTS_DIR="$(dirname "$UPSTREAM")/.github/agents"
|
||||
LOCAL_AGENTS_DIR="$(resolve_local_agents)"
|
||||
LOCAL_AGENTS_ROOT="$(dirname "$LOCAL_AGENTS_DIR")"
|
||||
refuse_symlink_components "$LOCAL_AGENTS_DIR" "$LOCAL_AGENTS_ROOT"
|
||||
AGENTS_UPDATED=0
|
||||
AGENTS_NEW=0
|
||||
AGENTS_CUSTOMIZED=0
|
||||
|
||||
if [[ -d "$UPSTREAM_AGENTS_DIR" ]]; then
|
||||
[[ ! -L "$UPSTREAM_AGENTS_DIR" ]] || die "refusing symlinked upstream agents directory: $UPSTREAM_AGENTS_DIR"
|
||||
log ""
|
||||
log "Agent profiles:"
|
||||
log " Upstream: $UPSTREAM_AGENTS_DIR"
|
||||
log " Local: $LOCAL_AGENTS_DIR"
|
||||
log ""
|
||||
|
||||
for agent_file in "$UPSTREAM_AGENTS_DIR"/*.md; do
|
||||
for agent_file in "$UPSTREAM_AGENTS_DIR"/*.agent.md; do
|
||||
[[ -f "$agent_file" ]] || continue
|
||||
# Resolve symlink and verify it's within the expected directory
|
||||
resolved="$(readlink -f "$agent_file" 2>/dev/null || realpath "$agent_file" 2>/dev/null)"
|
||||
upstream_canon="$(readlink -f "$UPSTREAM_AGENTS_DIR" 2>/dev/null || realpath "$UPSTREAM_AGENTS_DIR" 2>/dev/null)"
|
||||
resolved="$(canonicalize "$agent_file")" || die "cannot canonicalize agent profile: $agent_file"
|
||||
upstream_canon="$(canonicalize "$UPSTREAM_AGENTS_DIR")" || die "cannot canonicalize upstream agents directory: $UPSTREAM_AGENTS_DIR"
|
||||
[[ "$resolved" == "$upstream_canon"/* ]] || { warn "skipping external symlink: $agent_file -> $resolved"; continue; }
|
||||
agent_name="$(basename "$agent_file")"
|
||||
local_agent="$LOCAL_AGENTS_DIR/$agent_name"
|
||||
|
||||
[[ ! -L "$local_agent" ]] || die "refusing symlinked agent destination: $local_agent"
|
||||
|
||||
if [[ ! -f "$local_agent" ]]; then
|
||||
log " + agent $agent_name (new)"
|
||||
AGENTS_NEW=$((AGENTS_NEW + 1))
|
||||
@@ -372,19 +421,23 @@ fi
|
||||
|
||||
# --- Agent profile deployment (apply phase) ---
|
||||
if { (( AGENTS_UPDATED + AGENTS_NEW > 0 )) || ( $FORCE_AGENTS && (( AGENTS_CUSTOMIZED > 0 )) ); } && [[ -d "$UPSTREAM_AGENTS_DIR" ]]; then
|
||||
refuse_symlink_components "$LOCAL_AGENTS_DIR" "$LOCAL_AGENTS_ROOT"
|
||||
mkdir -p "$LOCAL_AGENTS_DIR"
|
||||
refuse_symlink_components "$LOCAL_AGENTS_DIR" "$LOCAL_AGENTS_ROOT"
|
||||
|
||||
for agent_file in "$UPSTREAM_AGENTS_DIR"/*.md; do
|
||||
for agent_file in "$UPSTREAM_AGENTS_DIR"/*.agent.md; do
|
||||
[[ -f "$agent_file" ]] || continue
|
||||
# Resolve symlink and verify it's within the expected directory
|
||||
resolved="$(readlink -f "$agent_file" 2>/dev/null || realpath "$agent_file" 2>/dev/null)"
|
||||
upstream_canon="$(readlink -f "$UPSTREAM_AGENTS_DIR" 2>/dev/null || realpath "$UPSTREAM_AGENTS_DIR" 2>/dev/null)"
|
||||
resolved="$(canonicalize "$agent_file")" || die "cannot canonicalize agent profile: $agent_file"
|
||||
upstream_canon="$(canonicalize "$UPSTREAM_AGENTS_DIR")" || die "cannot canonicalize upstream agents directory: $UPSTREAM_AGENTS_DIR"
|
||||
[[ "$resolved" == "$upstream_canon"/* ]] || { warn "skipping external symlink: $agent_file -> $resolved"; continue; }
|
||||
agent_name="$(basename "$agent_file")"
|
||||
local_agent="$LOCAL_AGENTS_DIR/$agent_name"
|
||||
|
||||
[[ ! -L "$local_agent" ]] || die "refusing symlinked agent destination: $local_agent"
|
||||
|
||||
if [[ ! -f "$local_agent" ]]; then
|
||||
cp "$agent_file" "$local_agent"
|
||||
copy_agent_atomically "$agent_file" "$local_agent"
|
||||
# Record baseline hash for new agent install
|
||||
agent_hash="$(file_sha256 "$local_agent")"
|
||||
record_baseline "$AGENT_BASELINE_FILE" "$agent_name" "$agent_hash"
|
||||
@@ -409,7 +462,7 @@ if { (( AGENTS_UPDATED + AGENTS_NEW > 0 )) || ( $FORCE_AGENTS && (( AGENTS_CUSTO
|
||||
|
||||
if $agent_custom; then
|
||||
if $FORCE_AGENTS; then
|
||||
cp "$agent_file" "$local_agent"
|
||||
copy_agent_atomically "$agent_file" "$local_agent"
|
||||
agent_hash="$(file_sha256 "$local_agent")"
|
||||
record_baseline "$AGENT_BASELINE_FILE" "$agent_name" "$agent_hash"
|
||||
log " ~ agent $agent_name (force-updated, baseline updated)"
|
||||
@@ -417,7 +470,7 @@ if { (( AGENTS_UPDATED + AGENTS_NEW > 0 )) || ( $FORCE_AGENTS && (( AGENTS_CUSTO
|
||||
warn "agent $agent_name appears customized — skipping (use --force-agents to override)"
|
||||
fi
|
||||
else
|
||||
cp "$agent_file" "$local_agent"
|
||||
copy_agent_atomically "$agent_file" "$local_agent"
|
||||
# Record/update baseline hash
|
||||
agent_hash="$(file_sha256 "$local_agent")"
|
||||
record_baseline "$AGENT_BASELINE_FILE" "$agent_name" "$agent_hash"
|
||||
|
||||
Reference in New Issue
Block a user