diff --git a/common/AGENTS.md b/common/AGENTS.md
index 98ff247..b26feba 100644
--- a/common/AGENTS.md
+++ b/common/AGENTS.md
@@ -34,6 +34,15 @@ looking for any other issues).
If you are performing a review of other's code, the same
principles apply.
+## Tasks
+
+The [agents/tasks/](agents/tasks/) directory contains reusable task definitions that you
+can execute. These are roughly equivalent to OpenCode "commands" or Claude Code
+"skills".
+
+If the user gives an instruction of the form "run task ..." or similar
+then you should read the agents/tasks/README.md file and find the relevant task and continue.
+
## Follow other guidelines
Look at the project README.md and look for guidelines
diff --git a/common/agents/tasks/README.md b/common/agents/tasks/README.md
new file mode 100644
index 0000000..1c1defe
--- /dev/null
+++ b/common/agents/tasks/README.md
@@ -0,0 +1,14 @@
+# Tasks
+
+Reusable task definitions for AI agents. See [AGENTS.md](../../AGENTS.md)
+for how to execute these tasks.
+
+Each `.md` file uses YAML frontmatter (`name`, `description`) followed
+by markdown instructions — compatible with Claude Code skills and
+OpenCode commands.
+
+## Available Tasks
+
+- **[perform-forge-review](perform-forge-review.md)** — Create AI-assisted code
+ reviews on GitHub, GitLab, or Forgejo. Builds review comments in a local JSONL
+ file for human inspection before submitting as a pending/draft review.
diff --git a/common/agents/tasks/perform-forge-review.md b/common/agents/tasks/perform-forge-review.md
new file mode 100644
index 0000000..6d687df
--- /dev/null
+++ b/common/agents/tasks/perform-forge-review.md
@@ -0,0 +1,475 @@
+---
+name: perform-forge-review
+description: Create AI-assisted code reviews on GitHub, GitLab, or Forgejo. Use when asked to review a PR/MR, analyze code changes, or provide review feedback.
+---
+
+# Perform Forge Review
+
+This task describes how to create and manage code reviews on GitHub, GitLab,
+and Forgejo/Gitea with human oversight.
+
+## Overview
+
+The recommended workflow:
+
+1. AI analyzes the PR diff and builds comments in a local JSONL file
+2. Submit the review as pending/draft (not visible to others yet)
+3. Human reviews in the forge UI, can add `@ai:` tasks for follow-up
+4. Human submits when satisfied
+
+Optionally after step 1, the JSONL review state can be passed off
+into another security context that has write access (or at least
+the ability to create draft reviews).
+
+## Attribution Convention
+
+**Review header:**
+```
+< your text here >
+
+---
+Assisted-by: ToolName (ModelName)
+
+
+Comments prefixed with "AI:" are unedited AI output.
+Executed from bootc-dev/infra task.
+
+```
+
+**Comment prefixes:**
+- `AI: ` — unedited AI output
+- `@ai: ` — human question/task for AI to process
+- No prefix — human has reviewed/edited
+
+**Comment types:**
+
+- `*Important*:` — should be resolved before merge
+- (no marker) — normal suggestion, can be addressed post-merge or ignored
+- `(low)` — minor nit, feel free to ignore
+- `**Question**:` — clarification needed (can combine with priority)
+
+Examples:
+- `AI: *Important*: This will panic on empty input`
+- `AI: Consider using iterators here`
+- `AI: (low) Rename to follow naming convention`
+- `AI: **Question**: Is this intentional?`
+
+**Filtering by priority:**
+
+If user instructions specify a priority filter (e.g., "important only"), create
+inline comments only for that priority level. Summarize the most relevant
+normal/low priority items in a `` section in the review body:
+
+```markdown
+
+Additional suggestions (normal/low priority)
+
+- `src/lib.rs:42` — Consider using iterators
+- `src/main.rs:15` — Minor: rename to follow convention
+
+```
+
+Avoid inline comments for purely positive observations (e.g., "Good approach here").
+These create noise and require manual resolution. If positive aspects are worth
+noting, briefly mention them in the review body.
+
+**Review body content:**
+
+Do not summarize or re-describe the PR changes — the commit messages already
+contain that information. The review body should only contain:
+- Attribution header (required)
+- Positive observations worth highlighting (optional, brief)
+- Concerns not tied to specific lines (optional)
+- Notes about missing context in the PR description (if any)
+- Collapsed lower-priority items when filtering (see above)
+
+---
+
+## Workflow
+
+**Note**: If you already have a pending review on this PR, you cannot create
+another. Check for existing pending reviews first (see API Reference) and
+either add comments to it or delete it before proceeding.
+
+### Step 1: Check Out the PR
+
+Check out the PR branch locally. This lets you read files directly to get
+accurate line numbers (diff line numbers are error-prone):
+
+```bash
+# GitHub
+gh pr checkout PR_NUMBER
+
+# GitLab
+glab mr checkout MR_IID
+
+# Forgejo (using forgejo-cli, or fall back to git fetch)
+forgejo-cli pr checkout PR_INDEX
+# or: git fetch origin pull/PR_INDEX/head:pr-PR_INDEX && git checkout pr-PR_INDEX
+```
+
+### Step 2: See the Code
+
+After checkout, determine the merge base (fork point) and review the changes:
+
+```bash
+# Find the merge base with the target branch (usually main)
+MERGE_BASE=$(git merge-base HEAD main)
+
+# View commit history since fork point
+git log --oneline $MERGE_BASE..HEAD
+
+# View the combined diff of all changes
+git diff $MERGE_BASE..HEAD
+
+# Or view each commit's diff separately
+git log -p $MERGE_BASE..HEAD
+```
+
+Review commit-by-commit to understand the logical structure of the changes.
+Pay attention to commit messages — they should explain the "why" behind each
+change. For larger PRs, reviewing each commit separately often provides better
+context than a single combined diff. Note any commits where the message doesn't
+match the code changes or where the reasoning is unclear.
+
+### Step 3: Build the Review
+
+The scripts in this task are located in the `scripts/` subdirectory relative to this
+file (i.e., `common/agents/tasks/scripts/` from the repo root, or wherever `common/`
+is synced in your project).
+
+First, start the review with metadata (including attribution):
+
+```bash
+scripts/forge-review-start.sh \
+ --body "Assisted-by: OpenCode (Claude Sonnet 4)
+
+AI-generated review. Comments prefixed with AI: are unedited." \
+ --review-file .git/review-123.jsonl
+```
+
+Then use `forge-review-append-comment.sh` to add comments. It validates that
+your comment targets the correct line by requiring a matching text fragment:
+
+```bash
+scripts/forge-review-append-comment.sh \
+ --file src/lib.rs \
+ --line 42 \
+ --match "fn process_data" \
+ --body "AI: *Important*: Missing error handling for empty input" \
+ --review-file .git/review-123.jsonl
+```
+
+This prevents comments from being attached to wrong lines if the file has changed.
+The script will error if the match text is not found on the specified line.
+
+Use a PR-specific review file (e.g., `.git/review-123.jsonl`) to avoid conflicts
+when reviewing multiple PRs.
+
+The JSONL file format:
+```
+{"body": "Review body with attribution..."}
+{"path": "src/lib.rs", "line": 42, "body": "AI: ..."}
+{"path": "src/main.rs", "line": 10, "body": "AI: ..."}
+```
+
+After adding comments, validate the review before submitting:
+
+```bash
+scripts/forge-review-submit-github.sh --dry-run owner repo 123 .git/review-123.jsonl
+# Output: Review validated: 5 pending comment(s) for owner/repo#123
+```
+
+### Step 4: Submit the Review
+
+Submit the review using the appropriate script for your forge:
+
+#### GitHub
+
+```bash
+scripts/forge-review-submit-github.sh owner repo 123 .git/review-123.jsonl
+```
+
+Requires: `gh` CLI configured with authentication.
+
+#### GitLab
+
+```bash
+export GITLAB_TOKEN="glpat-xxxx"
+scripts/forge-review-submit-gitlab.sh 12345 67 .git/review-67.jsonl
+```
+
+For GitLab renames, use `--old-path` when appending comments.
+
+#### Forgejo
+
+```bash
+export FORGEJO_TOKEN="xxxx"
+export FORGEJO_URL="https://codeberg.org"
+scripts/forge-review-submit-forgejo.sh owner repo 123 .git/review-123.jsonl
+```
+
+All submit scripts read the review body from the metadata entry in the JSONL file,
+so no separate body argument is needed.
+
+---
+
+## Processing @ai: Tasks
+
+When a human adds `@ai: ` to a comment, the AI should:
+
+1. Check for existing pending review (see API Reference below)
+2. Find comments containing `@ai:`
+3. Read the question/task and relevant code context
+4. Generate a response
+5. Update the comment, appending:
+
+```markdown
+@ai: Is this error handling correct?
+
+---
+**AI Response**: No, the error is being silently ignored. Consider...
+```
+
+The human can then:
+- Edit to remove the `@ai:` prefix if satisfied
+- Add follow-up `@ai:` tasks
+- Delete the comment if not useful
+- Submit when done
+
+---
+
+## Platform Comparison
+
+| Feature | GitHub | GitLab | Forgejo |
+|---------|--------|--------|---------|
+| Pending/Draft Reviews | ✅ | ✅ | ✅ |
+| Edit Pending Comments | ✅ (GraphQL) | ✅ (REST) | ❌ |
+| Delete Pending Comments | ✅ | ✅ | ✅ |
+| Add to Pending Review | ✅ | ✅ | ✅ |
+| Inline Comments | ✅ | ✅ | ✅ |
+| Submit with State | ✅ | ✅* | ✅ |
+| CLI Support | gh | glab | tea |
+
+*GitLab handles APPROVE separately via Approvals API.
+
+---
+
+## API Reference
+
+Direct API calls for advanced operations (editing, deleting, submitting).
+
+### GitHub
+
+#### Check for Existing Pending Review
+
+```bash
+gh api graphql -f query='
+{
+ repository(owner: "OWNER", name: "REPO") {
+ pullRequest(number: PR_NUMBER) {
+ reviews(last: 10, states: [PENDING]) {
+ nodes {
+ id
+ databaseId
+ body
+ comments(first: 100) {
+ nodes { id body path line }
+ }
+ }
+ }
+ }
+ }
+}'
+```
+
+#### Edit a Pending Comment
+
+REST returns 404 for pending comments. Use GraphQL:
+
+```bash
+gh api graphql -f query='
+mutation {
+ updatePullRequestReviewComment(input: {
+ pullRequestReviewCommentId: "PRRC_xxxxx",
+ body: "Updated comment text"
+ }) {
+ pullRequestReviewComment { id body }
+ }
+}'
+```
+
+#### Add Comment to Existing Pending Review
+
+```bash
+gh api graphql -f query='
+mutation {
+ addPullRequestReviewThread(input: {
+ pullRequestReviewId: "PRR_xxxxx",
+ body: "AI: New comment",
+ path: "src/lib.rs",
+ line: 50,
+ side: RIGHT
+ }) {
+ thread { id }
+ }
+}'
+```
+
+#### Delete a Pending Comment
+
+```bash
+gh api graphql -f query='
+mutation {
+ deletePullRequestReviewComment(input: {
+ id: "PRRC_xxxxx"
+ }) {
+ pullRequestReviewComment { id }
+ }
+}'
+```
+
+#### Submit the Review
+
+```bash
+gh api repos/OWNER/REPO/pulls/PR_NUMBER/reviews/REVIEW_ID/events \
+ -X POST -f event="COMMENT" # or REQUEST_CHANGES or APPROVE
+```
+
+### GitLab
+
+#### Check for Existing Draft Notes
+
+```bash
+curl --header "PRIVATE-TOKEN: $TOKEN" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes"
+```
+
+#### Edit a Draft Note
+
+```bash
+curl --request PUT \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ --form-string "note=Updated comment text" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/$NOTE_ID"
+```
+
+#### Delete a Draft Note
+
+```bash
+curl --request DELETE \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/$NOTE_ID"
+```
+
+#### Submit Review (Publish All Drafts)
+
+```bash
+curl --request POST \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes/bulk_publish"
+```
+
+#### Approve MR (Separate from Review)
+
+```bash
+curl --request POST \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/approve"
+```
+
+### Forgejo
+
+#### List Reviews (Find Pending)
+
+```bash
+curl -H "Authorization: token $TOKEN" \
+ "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews"
+```
+
+Look for reviews with `state: "PENDING"`.
+
+#### Add Comment to Existing Pending Review
+
+```bash
+curl -X POST \
+ -H "Authorization: token $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"path": "src/lib.rs", "new_position": 50, "body": "AI: Comment"}' \
+ "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID/comments"
+```
+
+#### Delete a Comment
+
+```bash
+curl -X DELETE \
+ -H "Authorization: token $TOKEN" \
+ "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID/comments/$COMMENT_ID"
+```
+
+#### Submit the Review
+
+```bash
+curl -X POST \
+ -H "Authorization: token $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{"event": "COMMENT", "body": "Review complete"}' \
+ "$FORGEJO_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews/$REVIEW_ID"
+```
+
+Valid event values: `APPROVE`, `REQUEST_CHANGES`, `COMMENT`
+
+**Note**: Forgejo does not support editing review comments via API.
+Workaround: Delete and recreate the comment.
+
+---
+
+## Notes
+
+- Always get the diff first to understand line positioning
+- Node IDs (GitHub) come from GraphQL queries
+- Project IDs (GitLab) can be numeric or URL-encoded paths
+- Pending reviews are only visible to their author
+- The JSONL workflow enables future sandboxed execution where the agent
+ runs with read-only access and a separate privileged process submits the review
+
+---
+
+## Related Projects
+
+This workflow was influenced by existing tools for distributed/local code review:
+
+### git-appraise
+
+https://github.com/google/git-appraise (5k+ stars)
+
+Google's distributed code review system stores reviews entirely in git using
+`git-notes` under `refs/notes/devtools/`. Key design choices:
+
+- **Single-line JSON** per entry with `cat_sort_uniq` merge strategy for
+ conflict-free merging
+- Separate refs for requests, comments, CI status, and robot comments
+- Reviews can be pushed/pulled like regular git data
+- Mirrors available for GitHub PRs and Phabricator
+
+The JSONL format in this workflow is compatible with potentially storing
+reviews in git-notes in the future. However, we intentionally avoid pushing
+review notes to remotes by default—the primary goal is local editing before
+forge submission, not distributed review storage.
+
+### git-bug
+
+https://github.com/git-bug/git-bug (9k+ stars)
+
+Distributed, offline-first issue tracker embedded in git. Uses git objects
+(not files) for storage with bridges to sync with GitHub/GitLab. Focused on
+issues rather than code review, but similar local-first philosophy.
+
+### Radicle
+
+https://radicle.xyz
+
+Fully decentralized code collaboration platform with its own P2P protocol.
+Issues and patches are stored in git. A complete forge alternative rather
+than a review tool, but demonstrates the broader space of distributed
+development tooling.
diff --git a/common/agents/tasks/scripts/forge-review-append-comment.sh b/common/agents/tasks/scripts/forge-review-append-comment.sh
new file mode 100755
index 0000000..2f61897
--- /dev/null
+++ b/common/agents/tasks/scripts/forge-review-append-comment.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/env bash
+# Append a review comment to a JSONL file with line content validation
+#
+# Usage: forge-review-append-comment.sh --file PATH --line NUM --match TEXT --body COMMENT [--review-file FILE]
+#
+# Arguments:
+# --file PATH File path relative to repo root (e.g., src/lib.rs)
+# --line NUM Line number in the file
+# --match TEXT Text that must appear on that line (for validation)
+# --body COMMENT The review comment body
+# --review-file FILE Output JSONL file (default: .git/review.jsonl)
+# --old-path PATH Original file path for renames (GitLab only)
+#
+# The review file must first be created with forge-review-start.sh.
+#
+# The script verifies that --match text appears on the specified line before
+# appending. This prevents comments from being attached to wrong lines when
+# the file has changed.
+#
+# Example:
+# forge-review-append-comment.sh \
+# --file src/lib.rs \
+# --line 42 \
+# --match "fn process_data" \
+# --body "AI: *Important*: Missing error handling for empty input"
+
+set -euo pipefail
+
+usage() {
+ cat >&2 <&2
+ usage
+ ;;
+ esac
+done
+
+# Validate required arguments
+if [[ -z "$FILE" ]]; then
+ echo "Error: --file is required" >&2
+ usage
+fi
+if [[ -z "$LINE" ]]; then
+ echo "Error: --line is required" >&2
+ usage
+fi
+if [[ -z "$MATCH" ]]; then
+ echo "Error: --match is required" >&2
+ usage
+fi
+if [[ -z "$BODY" ]]; then
+ echo "Error: --body is required" >&2
+ usage
+fi
+
+# Validate line is a number
+if ! [[ "$LINE" =~ ^[0-9]+$ ]]; then
+ echo "Error: --line must be a positive integer, got: $LINE" >&2
+ exit 1
+fi
+
+# Check file exists
+if [[ ! -f "$FILE" ]]; then
+ echo "Error: File not found: $FILE" >&2
+ exit 1
+fi
+
+# Extract the actual line content
+ACTUAL_LINE=$(sed -n "${LINE}p" "$FILE")
+
+if [[ -z "$ACTUAL_LINE" ]]; then
+ echo "Error: Line $LINE does not exist in $FILE" >&2
+ exit 1
+fi
+
+# Check if match text appears on the line
+if [[ "$ACTUAL_LINE" != *"$MATCH"* ]]; then
+ echo "Error: Match text not found on line $LINE" >&2
+ echo " Expected to find: $MATCH" >&2
+ echo " Actual line content: $ACTUAL_LINE" >&2
+ exit 1
+fi
+
+# Default review file
+if [[ -z "$REVIEW_FILE" ]]; then
+ REVIEW_FILE=".git/review.jsonl"
+fi
+
+# Verify review file exists (must be created with forge-review-start.sh first)
+if [[ ! -f "$REVIEW_FILE" ]]; then
+ echo "Error: Review file not found: $REVIEW_FILE" >&2
+ echo "Create it first with forge-review-start.sh" >&2
+ exit 1
+fi
+
+# Build the JSON object
+if [[ -n "$OLD_PATH" ]]; then
+ # Include old_path for GitLab renames
+ jq -n --arg path "$FILE" --argjson line "$LINE" --arg body "$BODY" --arg old_path "$OLD_PATH" \
+ '{path: $path, line: $line, body: $body, old_path: $old_path}' >> "$REVIEW_FILE"
+else
+ jq -n --arg path "$FILE" --argjson line "$LINE" --arg body "$BODY" \
+ '{path: $path, line: $line, body: $body}' >> "$REVIEW_FILE"
+fi
+
+echo "Added comment for $FILE:$LINE"
diff --git a/common/agents/tasks/scripts/forge-review-start.sh b/common/agents/tasks/scripts/forge-review-start.sh
new file mode 100755
index 0000000..47e7efc
--- /dev/null
+++ b/common/agents/tasks/scripts/forge-review-start.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# Start a new forge review by creating a JSONL file with metadata
+#
+# Usage: forge-review-start.sh --body TEXT [--review-file FILE]
+#
+# Arguments:
+# --body TEXT Review body text (must include attribution)
+# --review-file FILE Output JSONL file (default: .git/review.jsonl)
+#
+# The first line of the JSONL file will contain review metadata:
+# {"body": "Review body text..."}
+#
+# Subsequent lines (added by forge-review-append-comment.sh) contain comments:
+# {"path": "src/lib.rs", "line": 42, "body": "..."}
+#
+# Example:
+# forge-review-start.sh --body "Assisted-by: OpenCode (Claude Sonnet 4)
+#
+# AI-generated review. Comments prefixed with AI: are unedited."
+
+set -euo pipefail
+
+usage() {
+ cat >&2 <&2
+ usage
+ ;;
+ esac
+done
+
+# Validate required arguments
+if [[ -z "$BODY" ]]; then
+ echo "Error: --body is required" >&2
+ usage
+fi
+
+# Default review file
+if [[ -z "$REVIEW_FILE" ]]; then
+ REVIEW_FILE=".git/review.jsonl"
+fi
+
+# Check if file already exists
+if [[ -f "$REVIEW_FILE" ]]; then
+ echo "Error: Review file already exists: $REVIEW_FILE" >&2
+ echo "Delete it first or use a different --review-file" >&2
+ exit 1
+fi
+
+# Ensure parent directory exists
+mkdir -p "$(dirname "$REVIEW_FILE")"
+
+# Write metadata as first line
+jq -n --arg body "$BODY" \
+ '{body: $body}' > "$REVIEW_FILE"
+
+echo "Review started: $REVIEW_FILE"
diff --git a/common/agents/tasks/scripts/forge-review-submit-forgejo.sh b/common/agents/tasks/scripts/forge-review-submit-forgejo.sh
new file mode 100755
index 0000000..11d621f
--- /dev/null
+++ b/common/agents/tasks/scripts/forge-review-submit-forgejo.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env bash
+# Submit a forge review to Forgejo/Gitea
+#
+# STATUS: DRAFT/UNTESTED - This script has not been tested against a real Forgejo/Gitea instance.
+#
+# Usage: forge-review-submit-forgejo.sh [--dry-run] OWNER REPO PR_INDEX [REVIEW_FILE]
+#
+# Options:
+# --dry-run Validate the review file without submitting
+#
+# Arguments:
+# OWNER Repository owner
+# REPO Repository name
+# PR_INDEX Pull request index number
+# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl)
+#
+# Environment:
+# FORGEJO_TOKEN or GITEA_TOKEN - Forgejo/Gitea authentication token (required)
+# FORGEJO_URL or GITEA_URL - Instance URL (required, e.g., https://codeberg.org)
+#
+# The JSONL file must be created with forge-review-start.sh and contain:
+# Line 1: {"body": "Review body with attribution..."}
+# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "..."}
+#
+# On success, removes the review file. On failure, preserves it.
+
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 [--dry-run] OWNER REPO PR_INDEX [REVIEW_FILE]" >&2
+ echo "" >&2
+ echo "Options:" >&2
+ echo " --dry-run Validate the review file without submitting" >&2
+ echo "" >&2
+ echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2
+ echo "" >&2
+ echo "Environment variables:" >&2
+ echo " FORGEJO_TOKEN or GITEA_TOKEN - Authentication token (required)" >&2
+ echo " FORGEJO_URL or GITEA_URL - Instance URL (required)" >&2
+ exit 1
+}
+
+DRY_RUN=0
+if [[ "${1:-}" == "--dry-run" ]]; then
+ DRY_RUN=1
+ shift
+fi
+
+if [[ $# -lt 3 ]]; then
+ usage
+fi
+
+OWNER="$1"
+REPO="$2"
+PR_INDEX="$3"
+REVIEW_FILE="${4:-.git/review.jsonl}"
+
+if [[ ! -f "$REVIEW_FILE" ]]; then
+ echo "Error: Review file not found: $REVIEW_FILE" >&2
+ exit 1
+fi
+
+# Parse JSONL: extract metadata and comments separately
+if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then
+ echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2
+ echo "$ALL_ENTRIES" >&2
+ exit 1
+fi
+
+# Extract metadata (first line) and comments (remaining lines)
+METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]')
+if [[ "$METADATA" == "null" ]]; then
+ echo "Error: Empty review file: $REVIEW_FILE" >&2
+ echo "Create the review file with forge-review-start.sh first" >&2
+ exit 1
+fi
+
+REVIEW_BODY=$(echo "$METADATA" | jq -r '.body')
+if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then
+ echo "Error: First line missing 'body' field" >&2
+ exit 1
+fi
+
+# Extract comments (all lines after the first)
+COMMENTS_RAW=$(echo "$ALL_ENTRIES" | jq '.[1:]')
+COMMENT_COUNT=$(echo "$COMMENTS_RAW" | jq 'length')
+
+# Validate each comment has required fields
+INVALID=$(echo "$COMMENTS_RAW" | jq '[.[] | select(.path == null or .body == null)] | length')
+if [[ "$INVALID" -gt 0 ]]; then
+ echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2
+ exit 1
+fi
+
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "Review validated: $COMMENT_COUNT pending comment(s) for $OWNER/$REPO#$PR_INDEX"
+ exit 0
+fi
+
+TOKEN="${FORGEJO_TOKEN:-${GITEA_TOKEN:-}}"
+BASE_URL="${FORGEJO_URL:-${GITEA_URL:-}}"
+
+if [[ -z "$TOKEN" ]]; then
+ echo "Error: FORGEJO_TOKEN or GITEA_TOKEN environment variable required" >&2
+ exit 1
+fi
+
+if [[ -z "$BASE_URL" ]]; then
+ echo "Error: FORGEJO_URL or GITEA_URL environment variable required" >&2
+ exit 1
+fi
+
+# Strip trailing slash from URL
+BASE_URL="${BASE_URL%/}"
+
+# Transform comments to Forgejo format (line -> new_position)
+COMMENTS=$(echo "$COMMENTS_RAW" | jq 'map({path, new_position: .line, body})')
+
+# Create the review (pending by default - omit event param)
+RESULT=$(jq -n \
+ --arg body "$REVIEW_BODY" \
+ --argjson comments "$COMMENTS" \
+ '{body: $body, comments: $comments}' | \
+curl -s -X POST \
+ -H "Authorization: token $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d @- \
+ "$BASE_URL/api/v1/repos/$OWNER/$REPO/pulls/$PR_INDEX/reviews" 2>&1)
+
+if echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then
+ echo "Review created successfully"
+ rm "$REVIEW_FILE"
+else
+ echo "Failed to create review: $RESULT" >&2
+ echo "Review file preserved at $REVIEW_FILE" >&2
+ exit 1
+fi
diff --git a/common/agents/tasks/scripts/forge-review-submit-github.sh b/common/agents/tasks/scripts/forge-review-submit-github.sh
new file mode 100755
index 0000000..f04256d
--- /dev/null
+++ b/common/agents/tasks/scripts/forge-review-submit-github.sh
@@ -0,0 +1,116 @@
+#!/usr/bin/env bash
+# Submit a forge review to GitHub
+#
+# Usage: forge-review-submit-github.sh [--dry-run] OWNER REPO PR_NUMBER [REVIEW_FILE]
+#
+# Options:
+# --dry-run Validate the review file without submitting
+#
+# Arguments:
+# OWNER Repository owner
+# REPO Repository name
+# PR_NUMBER Pull request number
+# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl)
+#
+# Environment:
+# GH_TOKEN or GITHUB_TOKEN - GitHub authentication token (optional if gh is configured)
+#
+# The JSONL file must be created with forge-review-start.sh and contain:
+# Line 1: {"body": "Review body with attribution..."}
+# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "..."}
+#
+# On success, removes the review file. On failure, preserves it.
+
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 [--dry-run] OWNER REPO PR_NUMBER [REVIEW_FILE]" >&2
+ echo "" >&2
+ echo "Options:" >&2
+ echo " --dry-run Validate the review file without submitting" >&2
+ echo "" >&2
+ echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2
+ exit 1
+}
+
+DRY_RUN=0
+if [[ "${1:-}" == "--dry-run" ]]; then
+ DRY_RUN=1
+ shift
+fi
+
+if [[ $# -lt 3 ]]; then
+ usage
+fi
+
+OWNER="$1"
+REPO="$2"
+PR_NUMBER="$3"
+REVIEW_FILE="${4:-.git/review.jsonl}"
+
+if [[ ! -f "$REVIEW_FILE" ]]; then
+ echo "Error: Review file not found: $REVIEW_FILE" >&2
+ exit 1
+fi
+
+# Parse JSONL: extract metadata and comments separately
+if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then
+ echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2
+ echo "$ALL_ENTRIES" >&2
+ exit 1
+fi
+
+# Extract metadata (first line) and comments (remaining lines)
+METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]')
+if [[ "$METADATA" == "null" ]]; then
+ echo "Error: Empty review file: $REVIEW_FILE" >&2
+ echo "Create the review file with forge-review-start.sh first" >&2
+ exit 1
+fi
+
+REVIEW_BODY=$(echo "$METADATA" | jq -r '.body')
+if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then
+ echo "Error: First line missing 'body' field" >&2
+ exit 1
+fi
+
+# Extract comments (all lines after the first)
+COMMENTS=$(echo "$ALL_ENTRIES" | jq '.[1:]')
+COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
+
+# Validate each comment has required fields
+INVALID=$(echo "$COMMENTS" | jq '[.[] | select(.path == null or .body == null)] | length')
+if [[ "$INVALID" -gt 0 ]]; then
+ echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2
+ exit 1
+fi
+
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "Review validated: $COMMENT_COUNT pending comment(s) for $OWNER/$REPO#$PR_NUMBER"
+ exit 0
+fi
+
+# Check gh CLI is available
+if ! command -v gh >/dev/null 2>&1; then
+ echo "Error: gh CLI not found. Install from https://cli.github.com/" >&2
+ exit 1
+fi
+
+# Create the review (pending by default - omit event param)
+RESULT=$(jq -n \
+ --arg body "$REVIEW_BODY" \
+ --argjson comments "$COMMENTS" \
+ '{body: $body, comments: $comments}' | \
+gh api "repos/$OWNER/$REPO/pulls/$PR_NUMBER/reviews" \
+ -X POST --input - 2>&1) || true
+
+# Check result and clean up
+if echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then
+ echo "Review created: $(echo "$RESULT" | jq -r '.html_url // .id')"
+ rm "$REVIEW_FILE"
+else
+ echo "Failed to create review: $RESULT" >&2
+ echo "Review file preserved at $REVIEW_FILE" >&2
+ # Common error: "User can only have one pending review per pull request"
+ exit 1
+fi
diff --git a/common/agents/tasks/scripts/forge-review-submit-gitlab.sh b/common/agents/tasks/scripts/forge-review-submit-gitlab.sh
new file mode 100755
index 0000000..ecea7ec
--- /dev/null
+++ b/common/agents/tasks/scripts/forge-review-submit-gitlab.sh
@@ -0,0 +1,177 @@
+#!/usr/bin/env bash
+# Submit a forge review to GitLab
+#
+# STATUS: DRAFT/UNTESTED - This script has not been tested against a real GitLab instance.
+#
+# Usage: forge-review-submit-gitlab.sh [--dry-run] PROJECT_ID MR_IID [REVIEW_FILE]
+#
+# Options:
+# --dry-run Validate the review file without submitting
+#
+# Arguments:
+# PROJECT_ID GitLab project ID (numeric or URL-encoded path)
+# MR_IID Merge request IID
+# REVIEW_FILE Path to JSONL file (default: .git/review.jsonl)
+#
+# Environment:
+# GITLAB_TOKEN or PRIVATE_TOKEN - GitLab authentication token (required)
+# GITLAB_URL - GitLab instance URL (default: https://gitlab.com)
+#
+# The JSONL file must be created with forge-review-start.sh and contain:
+# Line 1: {"body": "Review body with attribution..."}
+# Line 2+: {"path": "src/lib.rs", "line": 42, "body": "...", "old_path": "..."}
+#
+# Note: old_path is optional for comments, defaults to path if not specified.
+#
+# On success, removes the review file. On failure, preserves it.
+
+set -euo pipefail
+
+usage() {
+ echo "Usage: $0 [--dry-run] PROJECT_ID MR_IID [REVIEW_FILE]" >&2
+ echo "" >&2
+ echo "Options:" >&2
+ echo " --dry-run Validate the review file without submitting" >&2
+ echo "" >&2
+ echo "REVIEW_FILE must be created with forge-review-start.sh first." >&2
+ echo "" >&2
+ echo "Environment variables:" >&2
+ echo " GITLAB_TOKEN or PRIVATE_TOKEN - GitLab authentication token (required)" >&2
+ echo " GITLAB_URL - GitLab instance URL (default: https://gitlab.com)" >&2
+ exit 1
+}
+
+DRY_RUN=0
+if [[ "${1:-}" == "--dry-run" ]]; then
+ DRY_RUN=1
+ shift
+fi
+
+if [[ $# -lt 2 ]]; then
+ usage
+fi
+
+PROJECT_ID="$1"
+MR_IID="$2"
+REVIEW_FILE="${3:-.git/review.jsonl}"
+
+TOKEN="${GITLAB_TOKEN:-${PRIVATE_TOKEN:-}}"
+GITLAB_URL="${GITLAB_URL:-https://gitlab.com}"
+# Strip trailing slash from URL
+GITLAB_URL="${GITLAB_URL%/}"
+
+if [[ ! -f "$REVIEW_FILE" ]]; then
+ echo "Error: Review file not found: $REVIEW_FILE" >&2
+ exit 1
+fi
+
+# Parse JSONL: extract metadata and comments separately
+if ! ALL_ENTRIES=$(jq -s '. // []' < "$REVIEW_FILE" 2>&1); then
+ echo "Error: Invalid JSONL syntax in $REVIEW_FILE" >&2
+ echo "$ALL_ENTRIES" >&2
+ exit 1
+fi
+
+# Extract metadata (first line) and comments (remaining lines)
+METADATA=$(echo "$ALL_ENTRIES" | jq '.[0]')
+if [[ "$METADATA" == "null" ]]; then
+ echo "Error: Empty review file: $REVIEW_FILE" >&2
+ echo "Create the review file with forge-review-start.sh first" >&2
+ exit 1
+fi
+
+REVIEW_BODY=$(echo "$METADATA" | jq -r '.body')
+if [[ -z "$REVIEW_BODY" || "$REVIEW_BODY" == "null" ]]; then
+ echo "Error: First line missing 'body' field" >&2
+ exit 1
+fi
+
+# Extract comments (all lines after the first)
+COMMENTS=$(echo "$ALL_ENTRIES" | jq '.[1:]')
+COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
+
+# Validate each comment has required fields
+INVALID=$(echo "$COMMENTS" | jq '[.[] | select(.path == null or .body == null)] | length')
+if [[ "$INVALID" -gt 0 ]]; then
+ echo "Error: $INVALID comment(s) missing required fields (path, body)" >&2
+ exit 1
+fi
+
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "Review validated: $COMMENT_COUNT pending comment(s) for GitLab project $PROJECT_ID MR !$MR_IID"
+ exit 0
+fi
+
+if [[ -z "$TOKEN" ]]; then
+ echo "Error: GITLAB_TOKEN or PRIVATE_TOKEN environment variable required" >&2
+ exit 1
+fi
+
+# Get MR version info first
+VERSION_RESPONSE=$(curl -s --header "PRIVATE-TOKEN: $TOKEN" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/versions" 2>&1)
+
+VERSION_INFO=$(echo "$VERSION_RESPONSE" | jq '.[0]' 2>/dev/null)
+
+if [[ -z "$VERSION_INFO" || "$VERSION_INFO" == "null" ]]; then
+ echo "Error: Failed to get MR version info: $VERSION_RESPONSE" >&2
+ echo "Review file preserved at $REVIEW_FILE" >&2
+ exit 1
+fi
+
+BASE_SHA=$(echo "$VERSION_INFO" | jq -r '.base_commit_sha')
+HEAD_SHA=$(echo "$VERSION_INFO" | jq -r '.head_commit_sha')
+START_SHA=$(echo "$VERSION_INFO" | jq -r '.start_commit_sha')
+
+# Validate we got valid SHAs
+if [[ "$BASE_SHA" == "null" || -z "$BASE_SHA" ]]; then
+ echo "Error: Invalid version info - missing base_commit_sha" >&2
+ echo "Review file preserved at $REVIEW_FILE" >&2
+ exit 1
+fi
+
+# Create draft notes from comments
+# Use process substitution to avoid subshell exit propagation issues
+COMMENT_COUNT_INT=$(echo "$COMMENTS" | jq 'length')
+if [[ "$COMMENT_COUNT_INT" -gt 0 ]]; then
+ while IFS= read -r comment; do
+ [[ -z "$comment" ]] && continue
+
+ path=$(echo "$comment" | jq -r '.path')
+ line_num=$(echo "$comment" | jq -r '.line')
+ body=$(echo "$comment" | jq -r '.body')
+ old_path=$(echo "$comment" | jq -r '.old_path // .path')
+
+ RESULT=$(curl -s --request POST \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ --form-string "note=$body" \
+ --form-string "position[position_type]=text" \
+ --form-string "position[base_sha]=$BASE_SHA" \
+ --form-string "position[head_sha]=$HEAD_SHA" \
+ --form-string "position[start_sha]=$START_SHA" \
+ --form-string "position[old_path]=$old_path" \
+ --form-string "position[new_path]=$path" \
+ --form-string "position[new_line]=$line_num" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/draft_notes" 2>&1)
+
+ if ! echo "$RESULT" | jq -e '.id' > /dev/null 2>&1; then
+ echo "Failed to create draft note for $path:$line_num: $RESULT" >&2
+ echo "Review file preserved at $REVIEW_FILE" >&2
+ exit 1
+ fi
+ done < <(echo "$COMMENTS" | jq -c '.[]')
+fi
+
+# Post the main review body as a note
+NOTE_RESULT=$(curl -s --request POST \
+ --header "PRIVATE-TOKEN: $TOKEN" \
+ --form-string "body=$REVIEW_BODY" \
+ "$GITLAB_URL/api/v4/projects/$PROJECT_ID/merge_requests/$MR_IID/notes" 2>&1)
+
+if ! echo "$NOTE_RESULT" | jq -e '.id' > /dev/null 2>&1; then
+ echo "Warning: Failed to post review summary note: $NOTE_RESULT" >&2
+ echo "Draft comments were created successfully" >&2
+fi
+
+echo "Review created successfully"
+rm "$REVIEW_FILE"