Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 96 additions & 1 deletion .github/workflows/pr-management.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ name: pr-management

on:
pull_request_target:
types: [opened]
types: [opened, reopened]
check_suite:
types: [completed]

jobs:
check-duplicates:
if: github.event_name == 'pull_request_target' && github.event.action == 'opened'
runs-on: ubuntu-latest
permissions:
contents: read
Expand Down Expand Up @@ -72,6 +75,7 @@ jobs:
fi

add-contributor-label:
if: github.event_name == 'pull_request_target' && github.event.action == 'opened'
runs-on: ubuntu-latest
permissions:
pull-requests: write
Expand All @@ -93,3 +97,94 @@ jobs:
labels: ['contributor']
});
}

inject-review-on-failure:
if: >-
github.event_name == 'check_suite' &&
github.event.check_suite.conclusion == 'failure' &&
github.event.check_suite.pull_requests[0]
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment review suggestion on failed CI
uses: actions/github-script@v8
with:
script: |
const prs = context.payload.check_suite.pull_requests;
if (!prs || prs.length === 0) return;

const prNumber = prs[0].number;
const marker = '<!-- inject-review-on-failure -->';

// Check for existing comment to avoid duplicates
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber
});

const existing = comments.find(c => c.body.includes(marker));
if (existing) {
core.info(`Review suggestion already posted on PR #${prNumber}`);
return;
}

const suite = context.payload.check_suite;
const body = `${marker}
**CI failed** on commit \`${suite.head_sha.substring(0, 7)}\`

Consider running \`/review\` to investigate the failure before pushing another attempt.`;

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body
});

core.info(`Posted review suggestion on PR #${prNumber}`);

post-pr-create-review-trigger:
if: >-
github.event_name == 'pull_request_target' &&
(github.event.action == 'opened' || github.event.action == 'reopened')
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Comment automated review notice
uses: actions/github-script@v8
with:
script: |
const pr = context.payload.pull_request;
const marker = '<!-- post-pr-review-trigger -->';

// Check for existing comment to avoid duplicates on reopen
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});

const existing = comments.find(c => c.body.includes(marker));
if (existing) {
core.info(`Review notice already posted on PR #${pr.number}`);
return;
}

const action = context.payload.action === 'reopened' ? 'reopened' : 'opened';
const body = `${marker}
**New PR ${action}** -- automated review will run on the next push.

To trigger a manual review, comment \`/review\` on this PR.`;

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body
});

core.info(`Posted review trigger notice on PR #${pr.number} (${action})`);

127 changes: 127 additions & 0 deletions .github/workflows/seed-verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
name: seed-verify

on:
push:
branches: [dev]
pull_request_target:

permissions:
contents: read
pull-requests: write

jobs:
check-seed-data:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- name: Detect seed file changes
id: detect
run: |
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
fi

SEED_PATTERNS="prisma/seed.* scripts/seed.* **/seed.sql"
CHANGED_SEEDS=""

for pattern in $SEED_PATTERNS; do
MATCHES=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- "$pattern" 2>/dev/null || true)
if [ -n "$MATCHES" ]; then
CHANGED_SEEDS="$CHANGED_SEEDS $MATCHES"
fi
done

CHANGED_SEEDS=$(echo "$CHANGED_SEEDS" | xargs)

if [ -z "$CHANGED_SEEDS" ]; then
echo "No seed files modified"
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Seed files modified: $CHANGED_SEEDS"
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "files=$CHANGED_SEEDS" >> "$GITHUB_OUTPUT"
fi

- name: Verify seed checksums
if: steps.detect.outputs.changed == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
FILES="${{ steps.detect.outputs.files }}"
CHECKSUM_FILE=".seed-checksums"
REPORT=""
HAS_MISMATCH="false"

echo "=== Seed Data Verification ==="
echo "Modified seed files: $FILES"
echo ""

for f in $FILES; do
if [ -f "$f" ]; then
CURRENT_HASH=$(sha256sum "$f" | awk '{print $1}')
echo "File: $f"
echo " SHA-256: $CURRENT_HASH"

if [ -f "$CHECKSUM_FILE" ]; then
EXPECTED_HASH=$(grep "$f" "$CHECKSUM_FILE" | awk '{print $1}' || true)
if [ -n "$EXPECTED_HASH" ] && [ "$CURRENT_HASH" != "$EXPECTED_HASH" ]; then
echo " Status: CHANGED (was $EXPECTED_HASH)"
HAS_MISMATCH="true"
REPORT="$REPORT\n- \`$f\`: checksum changed"
else
echo " Status: OK (new or matching)"
REPORT="$REPORT\n- \`$f\`: new or matching checksum"
fi
else
echo " Status: No baseline checksum file found"
REPORT="$REPORT\n- \`$f\`: no baseline (.seed-checksums missing)"
fi
echo ""
fi
done

# Post summary comment on PRs
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
MARKER="<!-- seed-verify -->"

if [ "$HAS_MISMATCH" = "true" ]; then
BODY="$MARKER
**Seed Data Verification**

Seed files were modified in this PR. Checksum mismatches detected:
$(echo -e "$REPORT")

Please verify these seed data changes are intentional and update \`.seed-checksums\` if needed."
else
BODY="$MARKER
**Seed Data Verification**

Seed files were modified in this PR:
$(echo -e "$REPORT")

No checksum mismatches found."
fi

# Upsert comment (avoid duplicates)
EXISTING=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -1)

if [ -n "$EXISTING" ]; then
gh api "repos/${{ github.repository }}/issues/comments/$EXISTING" \
-X PATCH -f body="$BODY"
else
gh pr comment "$PR_NUMBER" --body "$BODY"
fi
fi

echo "=== Verification complete ==="
84 changes: 84 additions & 0 deletions .github/workflows/workflow-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: workflow-sync-guard

on:
push:
branches: [dev]
pull_request_target:

permissions:
contents: read
pull-requests: write

jobs:
check-workflow-changes:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
fetch-depth: 0

- name: Detect workflow file changes
id: detect
run: |
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
else
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
fi

CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- '.github/workflows/' 2>/dev/null || true)

if [ -z "$CHANGED" ]; then
echo "No workflow files modified"
echo "changed=false" >> "$GITHUB_OUTPUT"
else
echo "Workflow files modified:"
echo "$CHANGED"
echo "changed=true" >> "$GITHUB_OUTPUT"
# Store as single-line comma-separated for output
FILES_LIST=$(echo "$CHANGED" | tr '\n' ',' | sed 's/,$//')
echo "files=$FILES_LIST" >> "$GITHUB_OUTPUT"
fi

- name: Post upstream compatibility warning
if: steps.detect.outputs.changed == 'true' && github.event_name == 'pull_request_target'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER="${{ github.event.pull_request.number }}"
FILES="${{ steps.detect.outputs.files }}"
MARKER="<!-- workflow-sync-guard -->"

# Format file list as markdown
FILE_LIST=""
IFS=',' read -ra FILE_ARRAY <<< "$FILES"
for f in "${FILE_ARRAY[@]}"; do
FILE_LIST="$FILE_LIST\n- \`$f\`"
done

BODY="$MARKER
**Workflow files modified -- verify upstream compatibility**

The following workflow files were changed in this PR:
$(echo -e "$FILE_LIST")

Before merging, please verify:
- [ ] Changes do not break upstream sync (\`upstream-sync.yml\` restores our workflows on merge)
- [ ] New workflows use \`pull_request_target\` (not \`pull_request\`) for fork compatibility
- [ ] Runner is set to \`ubuntu-latest\` (not blacksmith or other custom runners)
- [ ] No secrets are exposed in workflow logs"

# Upsert comment (avoid duplicates)
EXISTING=$(gh api "repos/${{ github.repository }}/issues/$PR_NUMBER/comments" \
--jq ".[] | select(.body | contains(\"$MARKER\")) | .id" | head -1)

if [ -n "$EXISTING" ]; then
gh api "repos/${{ github.repository }}/issues/comments/$EXISTING" \
-X PATCH -f body="$BODY"
else
gh pr comment "$PR_NUMBER" --body "$BODY"
fi
61 changes: 61 additions & 0 deletions docs/ai-guardrails/adr/007-multi-model-delegation-gates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# ADR 007: Multi-Model Delegation Gates

## Status

Accepted

## Context

Claude Code routes all tasks to a single provider (Anthropic) via Codex delegation gates (7 hooks). OpenCode supports multiple providers (ZAI, OpenAI, Anthropic via OpenRouter, Google, etc.) and can assign different models per agent via `agent.model`.

This multi-model capability is OpenCode's primary competitive advantage. However, without guardrails, agents may be assigned suboptimal models (expensive models for trivial tasks, weak models for complex tasks), parallel execution may exceed rate limits, and costs may spiral without visibility.

## Decision

Add five delegation gates to `guardrail.ts` that leverage OpenCode's multi-provider architecture:

### 1. agent-model-mapping (chat.params)
Advisory that logs when an agent is running on a model tier that doesn't match its expected workload. Three tiers: high (implement, security, architect), standard (review, tdd, build-error), low (explore, doc-updater, investigate).

### 2. delegation-budget-gate (tool.execute.before for task)
Hard block that limits concurrent parallel tasks to `maxParallelTasks` (default 5). Tracks `active_task_count` in state.json, incremented on task start and decremented on task completion.

### 3. cost-tracking (chat.params)
Counts `llm_call_count` per session and tracks `llm_calls_by_provider` for per-provider cost visibility. Actual cost calculation requires post-call usage data not available at chat.params time.

### 4. parallel-execution-gate (tool.execute.before for task)
Integrated with delegation-budget-gate. Prevents unbounded parallel execution that could hit provider rate limits.

### 5. verify-agent-output (tool.execute.after for task)
Advisory that detects trivially short agent output (< 20 characters), indicating the agent may have failed silently.

## Mapping to Claude Code Codex Gates

| Claude Code | OpenCode | Evolution |
|---|---|---|
| codex-task-gate | delegation-budget-gate | Single Codex → any provider |
| codex-model-gate | agent-model-mapping | Fixed model → per-agent tier |
| codex-parallel-gate | parallel-execution-gate | Same + per-provider limits |
| codex-cost-gate | cost-tracking | Codex API → all providers |
| codex-output-gate | verify-agent-output | Equivalent |

## Consequences

### Positive

- OpenCode gains structured cost visibility across all providers
- Unbounded parallel execution is prevented
- Model-agent mismatch is logged for optimization
- The pattern extends naturally to per-provider rate limits in future

### Negative

- `active_task_count` tracking may drift if a task crashes without completing the after hook; periodic reconciliation may be needed
- Tier assignments are static; dynamic assignment based on task complexity would be more accurate but requires deeper integration

## Sources

- `packages/opencode/src/tool/task.ts` — task delegation with agent model override
- `packages/opencode/src/agent/agent.ts` — agent model field and config merge
- `@opencode-ai/plugin` — Hooks interface (chat.params, tool.execute.before/after)
- Claude Code Codex delegation gates (7 hooks) — reference implementation
Loading
Loading