← Back to task

Commit 55f9203a

commit 55f9203a3fbe2dbb89d0bcd76825c50ef665550a
Author: Ben Sima <ben@bensima.com>
Date:   Tue Feb 10 20:47:41 2026

    Omni/Task+Ide: automate patch-based dev-review-release
    
    Add task trailer and patchset CLI support.
    
    Add patchset_count schema + JSON exposure.
    
    Add scoped dev/review/integrator loop runner for dogfooding.
    
    Task-Id: t-565

diff --git a/Omni/Ide/DEV_REVIEW_RELEASE.md b/Omni/Ide/DEV_REVIEW_RELEASE.md
new file mode 100644
index 00000000..d4aa2093
--- /dev/null
+++ b/Omni/Ide/DEV_REVIEW_RELEASE.md
@@ -0,0 +1,77 @@
+# Dev → Review → Release Automation
+
+This workflow runs three independent agent loops in dedicated worktrees.
+
+## Safety Guarantees
+
+- Existing worktrees are never deleted or modified by setup.
+- New worktrees are created under `_/worktrees/t-565` by default.
+- Branch cleanup is dry-run by default.
+- Worktree directories are never removed automatically.
+
+## 1) Create dedicated worktrees
+
+```bash
+Omni/Ide/dev-review-release.sh setup-worktrees
+```
+
+Default worktrees:
+- `_/worktrees/t-565/dev`
+- `_/worktrees/t-565/test`
+- `_/worktrees/t-565/live`
+
+Optional:
+
+```bash
+Omni/Ide/dev-review-release.sh setup-worktrees --root _/worktrees/my-flow --base live
+```
+
+## 2) Run loops (tmux: one window per role)
+
+```bash
+Omni/Ide/dev-review-release.sh loop --role dev
+Omni/Ide/dev-review-release.sh loop --role review
+Omni/Ide/dev-review-release.sh loop --role integrator
+```
+
+Optional flags:
+- `--interval 20`
+- `--provider claude-code`
+- `--root _/worktrees/t-565`
+- `--parent t-565` (scope to one epic)
+- `--task-id t-565.2` (scope to one task)
+- `--once`
+- `--dry-run`
+
+## 3) Branch cleanup
+
+Preview only:
+
+```bash
+Omni/Ide/dev-review-release.sh cleanup-branches
+```
+
+Apply deletion of done task branches:
+
+```bash
+Omni/Ide/dev-review-release.sh cleanup-branches --apply
+```
+
+## Task State Flow
+
+- dev loop: picks `open`/`in-progress` ready tasks
+- review loop: picks `review` tasks
+- integrator loop: picks `approved` tasks
+
+When dev produces a new task-branch commit SHA, it automatically increments
+`patchset_count` via `task patchset <id> --increment`.
+
+Expected lifecycle:
+
+`open -> in-progress -> review -> approved -> done`
+
+## Notes
+
+- Task branch naming convention: `t-XXX`
+- One task branch should contain exactly one logical commit.
+- Revisions should amend that commit (new patchset), not add extra commits.
diff --git a/Omni/Ide/README.md b/Omni/Ide/README.md
index 11c9f7c3..ff192eab 100644
--- a/Omni/Ide/README.md
+++ b/Omni/Ide/README.md
@@ -3,6 +3,7 @@
 ## Additional Documentation
 
 - **[ORCHESTRATOR.md](ORCHESTRATOR.md)** - Automated coder/reviewer loop (pi-orchestrate)
+- **[DEV_REVIEW_RELEASE.md](DEV_REVIEW_RELEASE.md)** - Dedicated dev/review/integrator loop automation
 
 ## Tools
 
@@ -57,6 +58,22 @@ Run tests:
 bild --test Omni/Task.hs         # Build and test a namespace
 ```
 
+### dev-review-release.sh
+
+Runs a 3-role looped workflow in dedicated worktrees (`dev`, `test`, `live`).
+
+Examples:
+```bash
+Omni/Ide/dev-review-release.sh setup-worktrees
+Omni/Ide/dev-review-release.sh loop --role dev
+Omni/Ide/dev-review-release.sh loop --role review
+Omni/Ide/dev-review-release.sh loop --role integrator
+
+# Optional: scope to one epic/task while dogfooding
+Omni/Ide/dev-review-release.sh loop --role dev --parent t-565
+Omni/Ide/dev-review-release.sh loop --role dev --task-id t-565.2
+```
+
 The convention for all programs in the omnirepo is to run their tests if the first argument is `test`. So for example:
 
 ```bash
diff --git a/Omni/Ide/Workflows/dev.md b/Omni/Ide/Workflows/dev.md
index f20b013e..7ca618f6 100644
--- a/Omni/Ide/Workflows/dev.md
+++ b/Omni/Ide/Workflows/dev.md
@@ -1,7 +1,7 @@
 ---
 model: claude-sonnet-4-5
 max_cost_cents: 200
-max_iterations: 50
+max_iterations: 80
 imports:
   - ../../../AGENTS.md
 tools:
@@ -13,81 +13,101 @@ tools:
 system_prompt: dev-system.md
 ---
 
-# Development Workflow
+# Dev Workflow (Patch-Based)
 
-You are a senior developer working on the Omni codebase. Complete the task provided below by following the development process.
+You are the **coder** in a dev → review → integrate workflow.
+
+The runtime context (Task ID, workspace, branch, namespace) is appended to this prompt by the loop runner.
+
+## Non-negotiable Rules
+
+1. Work only in the provided workspace.
+2. Never delete or mutate unrelated worktrees.
+3. One task = one commit on branch `t-XXX`.
+4. Revisions are **amend** operations on that same commit.
+5. Commit message must include trailer: `Task-Id: t-XXX`.
+6. Do not push.
 
 ## Process
 
-### Phase 0: Init (Pre-flight Check)
-Before making any changes, verify the environment is clean:
+### 0) Pre-flight
 
-1. **Check git status**: Run `git status --porcelain`
-   - If there are uncommitted changes, STOP and report: "Cannot start: uncommitted changes in worktree"
-   - List the dirty files so the user can address them
+1. Check clean tree:
+   ```bash
+   git status --porcelain
+   ```
+   If dirty: stop and report exact files.
 
-2. **Check namespace builds** (if namespace is specified):
-   - Run `bild <namespace>` to verify it compiles
-   - If it fails, STOP and report: "Cannot start: namespace is broken"
-   - Include the build error so the user can fix it first
+2. Ensure task branch is checked out (`t-XXX`).
+   - If branch exists: checkout it.
+   - If not: create from base branch (`live`) and checkout.
 
-Only proceed to Phase 1 if both checks pass.
+3. Verify branch shape:
+   ```bash
+   git rev-list --count live..HEAD
+   ```
+   Must be `0` (new task) or `1` (existing patchset). If greater than 1, stop and report.
 
-### Phase 1: Understand
-- Read the task requirements carefully
-- Load relevant skills: `skill(operation="load", query="Coder")` for implementation guidance
-- Identify what the task is asking for
+### 1) Understand
 
-### Phase 2: Research
-- Explore the codebase to understand context
-- Find existing patterns to follow
-- Identify files that need to change
+- Read task details and relevant files.
+- Load coder skill:
+  ```bash
+  skill(operation="load", query="Coder")
+  ```
 
-### Phase 3: Plan
-- List the specific changes needed
-- Identify risks or unknowns
-- State your approach clearly
+### 2) Implement
 
-### Phase 4: Implement
-- Make minimal, focused changes
-- Follow existing code conventions
-- One logical change at a time
+- Make minimal changes for the task.
+- Follow repository conventions from AGENTS.md.
 
-### Phase 5: Test
-- Run `bild <namespace>` to verify compilation
-- Run `bild --test <namespace>` if tests exist
-- Fix any errors before proceeding
+### 3) Verify
 
-### Phase 6: Review
-- Re-read the diff: `git diff`
-- Check for:
-  - Unnecessary changes
-  - Missing error handling
-  - Code style issues
-- Self-correct if needed
+- Run fast checks first (`typecheck.sh` where applicable).
+- Run `bild <namespace>` for the affected namespace.
+- Run tests when available: `bild --test <namespace>`.
 
-### Phase 7: Commit
-- Run `lint --fix <files>` on changed files
-- Stage changes: `git add <files>`
-- Commit with clear message: `git commit -m "..."`
+### 4) Commit / Amend
 
-## On Failure
+- If no commit exists on `live..HEAD`: create one.
+- If one commit already exists: amend (`git commit --amend`).
+- Keep exactly one commit on the branch.
 
-If you encounter an unrecoverable error after making changes:
+Commit message requirements:
+- Clear subject line
+- Optional body
+- Trailer line exactly:
+  ```
+  Task-Id: t-XXX
+  ```
 
-1. **Revert all changes**: Run `git checkout -- .` to restore the working tree
-2. **Report what went wrong**: Explain the failure clearly
-3. **Suggest next steps**: What should be fixed before retrying?
+You may generate the trailer via:
+```bash
+task trailer t-XXX
+```
 
-Do NOT leave the worktree in a broken state.
+### 5) Handoff to Review
 
-## Output
+After successful verification and commit/amend:
 
-Provide a summary:
-- What was changed
-- How it was tested
-- Any follow-up work needed
+1. Add task comment with summary + commit hash.
+2. Set task status to `review`:
+   ```bash
+   task update t-XXX review --json
+   ```
 
-## Task
+## Failure Handling
+
+If you cannot complete safely:
+
+1. Leave repository in a sane state (no half-finished destructive actions).
+2. Add task comment with failure reason and next step.
+3. Set status back to `open` only if you intentionally abort implementation.
+
+## Output
 
-The task to complete is provided in the user message.
+Report:
+- files changed
+- verification commands/results
+- whether you created/amended the single task commit
+- exact commit hash
diff --git a/Omni/Ide/Workflows/integrator.md b/Omni/Ide/Workflows/integrator.md
index 3bf54535..01cac619 100644
--- a/Omni/Ide/Workflows/integrator.md
+++ b/Omni/Ide/Workflows/integrator.md
@@ -1,57 +1,90 @@
-# Integrator Workflow
+# Integrator Workflow (Patch-Based)
 
-Verifies builds, runs tests, checks integration.
+You are the **integrator** in a dev → review → integrate workflow.
 
-## Role
+Runtime context (task id, branch, workspace) is appended by the loop runner.
 
-You are an integrator. Your job is to:
-1. Verify the codebase builds successfully
-2. Run all tests and report results
-3. Check for integration issues
-4. Report pass/fail status with details
+## Goals
 
-## Spawn Configuration
+1. Integrate approved task commit from `t-XXX` into `live`.
+2. Verify build/tests after integration.
+3. Mark task done and clean up branch pointer safely.
 
-```yaml
-model: claude-sonnet-4-20250514
-max_iterations: 200
-tools:
-  - Read
-  - Bash
-  - Grep
-  - Glob
-```
+## Hard Rules
+
+1. Work only in the provided live workspace.
+2. Never delete or mutate unrelated worktrees.
+3. Do not delete worktree directories.
+4. Integrate exactly one commit from task branch.
+
+## Process
+
+### 0) Pre-flight
 
-## Protocol
+1. Ensure clean live workspace:
+   ```bash
+   git status --porcelain
+   ```
 
-1. **Build check**:
+2. Ensure task status is `approved` before integrating:
    ```bash
-   cd /home/ben/omni/ava && Omni/Ide/run.sh [namespace]
+   task show t-XXX --json | jq -r '.taskStatus'
    ```
-2. **Test check**: Run test suite if present
-3. **Lint check**: Run linters if configured
-4. **Integration check**: Verify components work together
 
-## Reporting
+3. Ensure task branch has exactly one commit relative to live:
+   ```bash
+   git rev-list --count live..t-XXX
+   ```
+   Must be `1`.
+
+### 1) Integrate
+
+1. Checkout `live` branch.
+2. Cherry-pick task commit onto live:
+   ```bash
+   git cherry-pick t-XXX
+   ```
+
+If conflicts occur:
+- Abort (`git cherry-pick --abort`)
+- Add task comment with conflict details
+- Set task back to `open`
+- Stop
+
+### 2) Verify
+
+Run namespace-specific checks:
+- `bild <namespace>`
+- `bild --test <namespace>` where available
 
-Report to overseer with structured output:
+If verification fails:
+- Revert cherry-pick commit (`git reset --hard HEAD~1`) only if commit is local/unpushed in this workflow run
+- Add task comment with failure details
+- Set task back to `open`
+- Stop
+
+### 3) Finalize
+
+On success:
+
+1. Add task comment with integrated commit hash.
+2. Set task status to done:
+   ```bash
+   task done t-XXX --json
+   ```
+3. Delete task branch pointer if not checked out elsewhere:
+   ```bash
+   git branch -d t-XXX
+   ```
+   If branch is checked out in another worktree, skip deletion safely.
+
+## Output Format
 
 ```
 BUILD: pass|fail
-TESTS: pass|fail (N passed, M failed)
-LINT: pass|fail
+TESTS: pass|fail
 INTEGRATION: pass|fail
-
-DETAILS:
-[any error messages or warnings]
-
 VERDICT: ready|blocked
+DETAILS:
+- ...
 ```
-
-## Failure Analysis
-
-If anything fails:
-1. Capture full error output
-2. Identify root cause if possible
-3. Suggest fix if obvious
-4. Report blocked status with actionable details
diff --git a/Omni/Ide/Workflows/reviewer.md b/Omni/Ide/Workflows/reviewer.md
index 6ead06e1..da80227f 100644
--- a/Omni/Ide/Workflows/reviewer.md
+++ b/Omni/Ide/Workflows/reviewer.md
@@ -1,76 +1,93 @@
-# Reviewer Workflow
+# Reviewer Workflow (Patch-Based)
 
-Reviews code for quality, correctness, and maintainability.
+You are the **reviewer** in a dev → review → integrate workflow.
 
-## Role
+Runtime context (task id, branch, workspace) is appended by the loop runner.
 
-You are a code reviewer. Your job is to:
-1. Review code changes for correctness
-2. Check for bugs, edge cases, security issues
-3. Evaluate code quality and maintainability
-4. Provide actionable feedback
+## Goals
 
-## Spawn Configuration
+1. Review the single task commit on branch `t-XXX`.
+2. Validate build/tests for the task namespace.
+3. Approve (`approved`) or reject (`open`) with actionable comments.
 
-```yaml
-model: claude-opus-4-6
-max_iterations: 200
-tools:
-  - Read
-  - Bash
-  - Grep
-  - Glob
-```
+## Hard Rules
 
-## Protocol
+1. Work only in the provided test workspace.
+2. Never delete or mutate unrelated worktrees.
+3. Do not rewrite the task commit.
+4. Do not create new commits for review.
 
-1. **Identify changes**: Check recent commits or specified files
-2. **Read code**: Understand what was implemented
-3. **Evaluate**:
-   - Correctness: Does it do what it should?
-   - Edge cases: Are errors handled?
-   - Security: Any vulnerabilities?
-   - Style: Follows conventions?
-   - Maintainability: Clear and documented?
-4. **Report**: Structured feedback to overseer
+## Process
 
-## Review Checklist
+### 0) Pre-flight
 
-- [ ] Logic correctness
-- [ ] Error handling
-- [ ] Input validation
-- [ ] Resource cleanup
-- [ ] Test coverage
-- [ ] Documentation
-- [ ] Naming clarity
-- [ ] No hardcoded values
-- [ ] No obvious security issues
+1. Ensure clean workspace:
+   ```bash
+   git status --porcelain
+   ```
+   If dirty, stop and report.
 
-## Reporting
+2. Check out the task branch tip in detached mode:
+   ```bash
+   git checkout live
+   git checkout --detach t-XXX
+   ```
 
-```
-VERDICT: approve|request-changes
+3. Confirm exactly one commit on task branch relative to live:
+   ```bash
+   git rev-list --count live..t-XXX
+   ```
+   Must be `1`.
+
+### 1) Review changes
+
+- Inspect diff:
+  ```bash
+  git show --stat t-XXX
+  git show t-XXX
+  ```
+- Check correctness, edge cases, maintainability.
+
+### 2) Verify
+
+- Run build/test commands relevant to namespace:
+  - `typecheck.sh <file>` where useful
+  - `bild <namespace>`
+  - `bild --test <namespace>` when tests exist
 
-CRITICAL (must fix):
-- [issue]: [file:line] [description]
+### 3) Decision
 
-SUGGESTIONS (nice to have):
-- [suggestion]: [file:line] [description]
+#### Approve
+If checks pass and code quality is acceptable:
+
+1. Add approval comment summarizing what passed.
+2. Set status to approved:
+   ```bash
+   task update t-XXX approved --json
+   ```
+
+#### Request changes
+If any critical issue exists:
+
+1. Add detailed comment (what failed, why, how to fix).
+2. Set status back to open:
+   ```bash
+   task update t-XXX open --json
+   ```
+
+## Output Format
+
+Use this in your final message:
 
-PRAISE (good patterns observed):
-- [what was done well]
 ```
+VERDICT: approve|request-changes
 
-## Severity Guidelines
+CRITICAL:
+- ...
 
-*Critical* - blocks approval:
-- Bugs that cause incorrect behavior
-- Security vulnerabilities
-- Missing error handling that could crash
-- Data corruption risks
+SUGGESTIONS:
+- ...
 
-*Suggestions* - optional improvements:
-- Style inconsistencies
-- Minor refactoring opportunities
-- Documentation gaps
-- Test coverage improvements
+VERIFICATION:
+- command -> result
+```
diff --git a/Omni/Ide/dev-review-release.sh b/Omni/Ide/dev-review-release.sh
new file mode 100755
index 00000000..ab4cf580
--- /dev/null
+++ b/Omni/Ide/dev-review-release.sh
@@ -0,0 +1,642 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+DEFAULT_WORKTREE_ROOT="_/worktrees/t-565"
+DEFAULT_BASE_BRANCH="live"
+DEFAULT_INTERVAL_SECONDS=20
+DEFAULT_TIMEOUT_SECONDS=1800
+DEFAULT_MAX_ITER=80
+DEFAULT_MAX_COST_CENTS=300
+
+usage() {
+  cat <<'EOF'
+Usage:
+  Omni/Ide/dev-review-release.sh setup-worktrees [--root PATH] [--base BRANCH]
+  Omni/Ide/dev-review-release.sh loop --role dev|review|integrator [options]
+  Omni/Ide/dev-review-release.sh cleanup-branches [--apply]
+
+Commands:
+  setup-worktrees   Create dedicated dev/test/live worktrees for this workflow.
+                    Existing worktrees are never deleted or modified.
+
+  loop              Poll tasks and run role workflow in a loop.
+                    Intended to run one role per tmux window.
+
+  cleanup-branches  Delete task branches (t-*) whose tasks are Done.
+                    Dry-run by default.
+
+Loop options:
+  --role ROLE       Required. dev | review | integrator
+  --root PATH       Worktree root (default: _/worktrees/t-565)
+  --base BRANCH     Base branch for worktrees (default: live)
+  --interval N      Poll interval in seconds (default: 20)
+  --provider NAME   Optional agentd provider (claude-code|anthropic|openrouter|ollama)
+  --parent ID       Restrict loop to tasks whose parent matches ID (epic scope)
+  --task-id ID      Restrict loop to a single task ID
+  --timeout SEC     agentd timeout per run (default: 1800)
+  --max-iter N      agentd max iterations per run (default: 80)
+  --max-cost CENTS  agentd max cost cents per run (default: 300)
+  --once            Process at most one task, then exit
+  --dry-run         Print what would run, do not invoke agentd
+
+Cleanup options:
+  --apply           Actually delete branches. Without this flag, cleanup is dry-run.
+  --root PATH       Worktree root (default: _/worktrees/t-565)
+
+Examples:
+  Omni/Ide/dev-review-release.sh setup-worktrees
+
+  Omni/Ide/dev-review-release.sh loop --role dev
+  Omni/Ide/dev-review-release.sh loop --role review
+  Omni/Ide/dev-review-release.sh loop --role integrator
+
+  # Scope to one epic (dogfood safely)
+  Omni/Ide/dev-review-release.sh loop --role dev --parent t-565
+
+  Omni/Ide/dev-review-release.sh cleanup-branches
+  Omni/Ide/dev-review-release.sh cleanup-branches --apply
+EOF
+}
+
+log() {
+  printf '[%s] %s\n' "$(date +'%Y-%m-%d %H:%M:%S')" "$*"
+}
+
+resolve_path() {
+  local p="$1"
+  if [[ "$p" = /* ]]; then
+    printf '%s\n' "$p"
+  else
+    printf '%s/%s\n' "$REPO_ROOT" "$p"
+  fi
+}
+
+workflow_path_for_role() {
+  local role="$1"
+  case "$role" in
+    dev) printf '%s/Omni/Ide/Workflows/dev.md\n' "$REPO_ROOT" ;;
+    review) printf '%s/Omni/Ide/Workflows/reviewer.md\n' "$REPO_ROOT" ;;
+    integrator) printf '%s/Omni/Ide/Workflows/integrator.md\n' "$REPO_ROOT" ;;
+    *)
+      echo "Unknown role: $role" >&2
+      exit 1
+      ;;
+  esac
+}
+
+workspace_for_role() {
+  local root="$1"
+  local role="$2"
+  case "$role" in
+    dev) printf '%s/dev\n' "$root" ;;
+    review) printf '%s/test\n' "$root" ;;
+    integrator) printf '%s/live\n' "$root" ;;
+    *)
+      echo "Unknown role: $role" >&2
+      exit 1
+      ;;
+  esac
+}
+
+branch_for_worktree() {
+  local role="$1"
+  case "$role" in
+    dev) printf 't-565-dev\n' ;;
+    review) printf 't-565-test\n' ;;
+    integrator) printf 't-565-live\n' ;;
+    *)
+      echo "Unknown role: $role" >&2
+      exit 1
+      ;;
+  esac
+}
+
+current_task_status() {
+  local tid="$1"
+  task show "$tid" --json | jq -r '.taskStatus'
+}
+
+task_branch_sha() {
+  local workspace="$1"
+  local tid="$2"
+  git -C "$workspace" rev-parse --verify "$tid^{commit}" 2>/dev/null || true
+}
+
+select_next_task() {
+  local role="$1"
+  local task_filter="$2"
+  local parent_filter="$3"
+
+  local jq_filter
+  case "$role" in
+    dev)
+      # shellcheck disable=SC2016
+      jq_filter='.[] | select(.taskType == "WorkTask" and (.taskStatus == "Open" or .taskStatus == "InProgress") and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
+      task ready --json \
+        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
+        | head -n 1
+      ;;
+    review)
+      # shellcheck disable=SC2016
+      jq_filter='.[] | select(.taskType == "WorkTask" and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
+      task list --status=review --json \
+        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
+        | head -n 1
+      ;;
+    integrator)
+      # shellcheck disable=SC2016
+      jq_filter='.[] | select(.taskType == "WorkTask" and ($task_id == "" or .taskId == $task_id) and ($parent_id == "" or (.taskParent // "") == $parent_id)) | .taskId'
+      task list --status=approved --json \
+        | jq -r --arg task_id "$task_filter" --arg parent_id "$parent_filter" "$jq_filter" \
+        | head -n 1
+      ;;
+    *)
+      echo "Unknown role: $role" >&2
+      exit 1
+      ;;
+  esac
+}
+
+ensure_clean_workspace() {
+  local workspace="$1"
+  local dirty
+  dirty="$(git -C "$workspace" status --porcelain)"
+  if [[ -n "$dirty" ]]; then
+    log "Workspace is dirty, refusing to run: $workspace"
+    printf '%s\n' "$dirty"
+    return 1
+  fi
+}
+
+prepare_workspace_for_task() {
+  local role="$1"
+  local workspace="$2"
+  local tid="$3"
+  local base_branch="$4"
+
+  case "$role" in
+    dev)
+      if git -C "$workspace" show-ref --verify --quiet "refs/heads/$tid"; then
+        git -C "$workspace" checkout "$tid" >/dev/null
+      else
+        git -C "$workspace" checkout "$base_branch" >/dev/null
+        git -C "$workspace" checkout -b "$tid" "$base_branch" >/dev/null
+      fi
+      ;;
+    review)
+      if ! git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$tid"; then
+        log "Task branch $tid does not exist yet, skipping"
+        return 1
+      fi
+      git -C "$workspace" checkout "$base_branch" >/dev/null
+      git -C "$workspace" checkout --detach "$tid" >/dev/null
+      ;;
+    integrator)
+      git -C "$workspace" checkout "$base_branch" >/dev/null
+      ;;
+    *)
+      echo "Unknown role: $role" >&2
+      exit 1
+      ;;
+  esac
+}
+
+build_prompt() {
+  local role="$1"
+  local workflow_file="$2"
+  local workspace="$3"
+  local tid="$4"
+
+  local workflow
+  workflow="$(<"$workflow_file")"
+
+  local task_json
+  task_json="$(task show "$tid" --json)"
+
+  local title desc status namespace trailer patchset_count
+  title="$(jq -r '.taskTitle' <<<"$task_json")"
+  desc="$(jq -r '.taskDescription' <<<"$task_json")"
+  status="$(jq -r '.taskStatus' <<<"$task_json")"
+  namespace="$(jq -r '.taskNamespace // ""' <<<"$task_json")"
+  trailer="$(jq -r '.trailer // (.taskId | "Task-Id: " + .)' <<<"$task_json")"
+  patchset_count="$(jq -r '.patchset_count // .taskPatchsetCount // 0' <<<"$task_json")"
+
+  printf '%s\n\n---\n\n' "$workflow"
+  printf '## Runtime Task Context\n\n'
+  printf '- Role: %s\n' "$role"
+  printf '- Task ID: %s\n' "$tid"
+  printf '- Branch: %s\n' "$tid"
+  printf '- Workspace: %s\n' "$workspace"
+  printf '- Status: %s\n' "$status"
+  printf '- Patchset: %s\n' "$patchset_count"
+  printf '- Trailer: %s\n' "$trailer"
+  if [[ -n "$namespace" ]]; then
+    printf '- Namespace: %s\n' "$namespace"
+  fi
+  printf '\n### Title\n%s\n\n' "$title"
+  printf '### Description\n%s\n\n' "$desc"
+  printf '### Hard Constraints\n'
+  printf '1. Never delete or mutate unrelated worktrees.\n'
+  printf '2. Operate only in the provided workspace path.\n'
+  printf '3. Keep one-task-one-commit discipline for this task branch.\n'
+}
+
+run_single_task() {
+  local role="$1"
+  local workspace="$2"
+  local base_branch="$3"
+  local provider="$4"
+  local timeout="$5"
+  local max_iter="$6"
+  local max_cost="$7"
+  local dry_run="$8"
+  local task_filter="$9"
+  local parent_filter="${10}"
+
+  local tid
+  tid="$(select_next_task "$role" "$task_filter" "$parent_filter")"
+
+  if [[ -z "$tid" ]]; then
+    log "No $role tasks ready${task_filter:+ (task=$task_filter)}${parent_filter:+ (parent=$parent_filter)}"
+    return 2
+  fi
+
+  log "Picked task $tid for role=$role"
+
+  local run_name
+  run_name="${role}-${tid}-$(date +%Y%m%d-%H%M%S)"
+
+  if [[ "$dry_run" == "true" ]]; then
+    log "DRY RUN: would execute agentd run for $run_name in $workspace"
+    return 0
+  fi
+
+  ensure_clean_workspace "$workspace" || return 1
+
+  if ! prepare_workspace_for_task "$role" "$workspace" "$tid" "$base_branch"; then
+    return 1
+  fi
+
+  local before_sha=""
+  if [[ "$role" == "dev" ]]; then
+    local s
+    s="$(current_task_status "$tid")"
+    if [[ "$s" == "Open" ]]; then
+      task start "$tid" --json >/dev/null || true
+    fi
+    before_sha="$(task_branch_sha "$workspace" "$tid")"
+  fi
+
+  local workflow_file
+  workflow_file="$(workflow_path_for_role "$role")"
+
+  local prompt
+  prompt="$(build_prompt "$role" "$workflow_file" "$workspace" "$tid")"
+
+  log "Starting agentd run: $run_name"
+
+  local -a cmd
+  cmd=(agentd run "$prompt" -n "$run_name" --fg --timeout "$timeout" --max-iter "$max_iter" --max-cost "$max_cost")
+  if [[ -n "$provider" ]]; then
+    cmd+=( -p "$provider" )
+  fi
+
+  set +e
+  (
+    cd "$workspace"
+    "${cmd[@]}"
+  )
+  local rc=$?
+  set -e
+
+  if [[ $rc -ne 0 ]]; then
+    log "Run failed for $tid (run=$run_name)"
+    task comment "$tid" "Automation ($role) failed in run $run_name; inspect agentd logs/status." --json >/dev/null || true
+    return $rc
+  fi
+
+  local new_status
+  new_status="$(current_task_status "$tid")"
+
+  if [[ "$role" == "dev" && ("$new_status" == "Open" || "$new_status" == "InProgress") ]]; then
+    log "Dev run succeeded but status is $new_status; promoting $tid to review"
+    task update "$tid" review --json >/dev/null || true
+    task comment "$tid" "Automation (dev) promoted task to review after successful run $run_name." --json >/dev/null || true
+    new_status="$(current_task_status "$tid")"
+  fi
+
+  if [[ "$role" == "dev" ]]; then
+    local after_sha
+    after_sha="$(task_branch_sha "$workspace" "$tid")"
+    if [[ -n "$after_sha" && "$after_sha" != "$before_sha" ]]; then
+      if patchset_json="$(task patchset "$tid" --increment --json 2>/dev/null)"; then
+        local new_patchset
+        new_patchset="$(jq -r '.patchset_count // .patchsetCount // "?"' <<<"$patchset_json")"
+        log "Bumped patchset for $tid to $new_patchset"
+      else
+        log "Failed to bump patchset for $tid"
+      fi
+    fi
+  fi
+
+  log "Completed task run for $tid (status now: $new_status)"
+  return 0
+}
+
+setup_worktrees_cmd() {
+  local root_rel="$DEFAULT_WORKTREE_ROOT"
+  local base_branch="$DEFAULT_BASE_BRANCH"
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --root)
+        root_rel="$2"
+        shift 2
+        ;;
+      --base)
+        base_branch="$2"
+        shift 2
+        ;;
+      -h|--help)
+        usage
+        exit 0
+        ;;
+      *)
+        echo "Unknown option: $1" >&2
+        usage
+        exit 1
+        ;;
+    esac
+  done
+
+  local root
+  root="$(resolve_path "$root_rel")"
+  mkdir -p "$root"
+
+  for role in dev review integrator; do
+    local workspace branch
+    workspace="$(workspace_for_role "$root" "$role")"
+    branch="$(branch_for_worktree "$role")"
+
+    if git -C "$workspace" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+      log "Worktree exists, leaving untouched: $workspace"
+      continue
+    fi
+
+    if git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$branch"; then
+      log "Creating worktree at $workspace from existing branch $branch"
+      git -C "$REPO_ROOT" worktree add "$workspace" "$branch"
+    else
+      log "Creating worktree at $workspace with branch $branch from $base_branch"
+      git -C "$REPO_ROOT" worktree add -b "$branch" "$workspace" "$base_branch"
+    fi
+  done
+
+  cat <<EOF
+
+Worktrees ready under: $root
+
+Start loops in tmux (one window each):
+  Omni/Ide/dev-review-release.sh loop --role dev --root "$root"
+  Omni/Ide/dev-review-release.sh loop --role review --root "$root"
+  Omni/Ide/dev-review-release.sh loop --role integrator --root "$root"
+EOF
+}
+
+loop_cmd() {
+  local role=""
+  local root_rel="$DEFAULT_WORKTREE_ROOT"
+  local base_branch="$DEFAULT_BASE_BRANCH"
+  local interval="$DEFAULT_INTERVAL_SECONDS"
+  local provider=""
+  local task_filter=""
+  local parent_filter=""
+  local timeout="$DEFAULT_TIMEOUT_SECONDS"
+  local max_iter="$DEFAULT_MAX_ITER"
+  local max_cost="$DEFAULT_MAX_COST_CENTS"
+  local once="false"
+  local dry_run="false"
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --role)
+        role="$2"
+        shift 2
+        ;;
+      --root)
+        root_rel="$2"
+        shift 2
+        ;;
+      --base)
+        base_branch="$2"
+        shift 2
+        ;;
+      --interval)
+        interval="$2"
+        shift 2
+        ;;
+      --provider)
+        provider="$2"
+        shift 2
+        ;;
+      --task-id)
+        task_filter="$2"
+        shift 2
+        ;;
+      --parent)
+        parent_filter="$2"
+        shift 2
+        ;;
+      --timeout)
+        timeout="$2"
+        shift 2
+        ;;
+      --max-iter)
+        max_iter="$2"
+        shift 2
+        ;;
+      --max-cost)
+        max_cost="$2"
+        shift 2
+        ;;
+      --once)
+        once="true"
+        shift
+        ;;
+      --dry-run)
+        dry_run="true"
+        shift
+        ;;
+      -h|--help)
+        usage
+        exit 0
+        ;;
+      *)
+        echo "Unknown option: $1" >&2
+        usage
+        exit 1
+        ;;
+    esac
+  done
+
+  if [[ -z "$role" ]]; then
+    echo "--role is required for loop" >&2
+    usage
+    exit 1
+  fi
+
+  local root workspace
+  root="$(resolve_path "$root_rel")"
+  workspace="$(workspace_for_role "$root" "$role")"
+
+  if ! git -C "$workspace" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+    echo "Workspace missing: $workspace" >&2
+    echo "Run setup first: Omni/Ide/dev-review-release.sh setup-worktrees --root \"$root\"" >&2
+    exit 1
+  fi
+
+  log "Starting $role loop"
+  log "workspace=$workspace base=$base_branch interval=${interval}s dry_run=$dry_run task_filter=${task_filter:-<none>} parent_filter=${parent_filter:-<none>}"
+
+  while true; do
+    set +e
+    run_single_task "$role" "$workspace" "$base_branch" "$provider" "$timeout" "$max_iter" "$max_cost" "$dry_run" "$task_filter" "$parent_filter"
+    rc=$?
+    set -e
+
+    if [[ "$once" == "true" ]]; then
+      exit 0
+    fi
+
+    if [[ $rc -eq 2 ]]; then
+      sleep "$interval"
+      continue
+    fi
+
+    if [[ $rc -ne 0 ]]; then
+      log "Loop iteration failed (rc=$rc), sleeping before retry"
+      sleep "$interval"
+      continue
+    fi
+
+    sleep 2
+  done
+}
+
+branch_checked_out_in_any_worktree() {
+  local branch="$1"
+  git -C "$REPO_ROOT" worktree list --porcelain | grep -q "^branch refs/heads/$branch$"
+}
+
+cleanup_branches_cmd() {
+  local apply="false"
+  local root_rel="$DEFAULT_WORKTREE_ROOT"
+
+  while [[ $# -gt 0 ]]; do
+    case "$1" in
+      --apply)
+        apply="true"
+        shift
+        ;;
+      --root)
+        root_rel="$2"
+        shift 2
+        ;;
+      -h|--help)
+        usage
+        exit 0
+        ;;
+      *)
+        echo "Unknown option: $1" >&2
+        usage
+        exit 1
+        ;;
+    esac
+  done
+
+  local root
+  root="$(resolve_path "$root_rel")"
+
+  log "Branch cleanup mode: $( [[ "$apply" == "true" ]] && echo APPLY || echo DRY-RUN )"
+  log "Worktree root safety scope: $root"
+
+  local branches
+  branches="$(git -C "$REPO_ROOT" for-each-ref --format='%(refname:short)' refs/heads/t-*)"
+
+  if [[ -z "$branches" ]]; then
+    log "No local task branches found"
+    return 0
+  fi
+
+  while IFS= read -r branch; do
+    [[ -z "$branch" ]] && continue
+
+    if [[ ! "$branch" =~ ^t-[0-9]+([.][0-9]+)*$ ]]; then
+      continue
+    fi
+
+    local task_json status
+    if ! task_json="$(task show "$branch" --json 2>/dev/null)"; then
+      log "Skip $branch (not a known task id)"
+      continue
+    fi
+
+    status="$(jq -r '.taskStatus // empty' <<<"$task_json")"
+
+    if [[ -z "$status" ]]; then
+      log "Skip $branch (task missing status)"
+      continue
+    fi
+
+    if [[ "$status" != "Done" ]]; then
+      log "Skip $branch (task status: $status)"
+      continue
+    fi
+
+    if branch_checked_out_in_any_worktree "$branch"; then
+      log "Skip $branch (currently checked out in a worktree)"
+      continue
+    fi
+
+    if [[ "$apply" == "true" ]]; then
+      log "Deleting branch: $branch"
+      git -C "$REPO_ROOT" branch -D "$branch"
+    else
+      log "Would delete branch: $branch"
+    fi
+  done <<< "$branches"
+}
+
+main() {
+  local command_name="${1:-}"
+  if [[ -z "$command_name" ]]; then
+    usage
+    exit 1
+  fi
+  shift || true
+
+  case "$command_name" in
+    setup-worktrees)
+      setup_worktrees_cmd "$@"
+      ;;
+    loop)
+      loop_cmd "$@"
+      ;;
+    cleanup-branches)
+      cleanup_branches_cmd "$@"
+      ;;
+    -h|--help|help)
+      usage
+      ;;
+    *)
+      echo "Unknown command: $command_name" >&2
+      usage
+      exit 1
+      ;;
+  esac
+}
+
+main "$@"
diff --git a/Omni/Task.hs b/Omni/Task.hs
index 271207aa..b1a1df95 100644
--- a/Omni/Task.hs
+++ b/Omni/Task.hs
@@ -67,6 +67,8 @@ parser =
         <> Cli.command "list" (Cli.info listParser (Cli.progDesc "List all tasks"))
         <> Cli.command "ready" (Cli.info readyParser (Cli.progDesc "Show ready tasks (not blocked)"))
         <> Cli.command "show" (Cli.info showParser (Cli.progDesc "Show detailed task information"))
+        <> Cli.command "trailer" (Cli.info trailerParser (Cli.progDesc "Emit git trailer for a task"))
+        <> Cli.command "patchset" (Cli.info patchsetParser (Cli.progDesc "Show or increment patchset count"))
         <> Cli.command "update" (Cli.info updateParser (Cli.progDesc "Update task status"))
         <> Cli.command "start" (Cli.info startParser (Cli.progDesc "Start working on a task (sets in-progress)"))
         <> Cli.command "done" (Cli.info doneParser (Cli.progDesc "Mark a task as done"))
@@ -412,9 +414,67 @@ doShow GlobalOpts {..} tidStr = do
     Nothing -> putText "Task not found"
     Just task ->
       if globalJson
-        then outputJson task
+        then outputJson <| taskJsonWithTrailer task
         else showTaskDetailed task
 
+-- | trailer command
+trailerParser :: Cli.Parser (IO ())
+trailerParser =
+  doTrailer
+    </ globalOptsParser
+    <*> Cli.strArgument (Cli.metavar "ID" <> Cli.help "Task ID")
+
+doTrailer :: GlobalOpts -> String -> IO ()
+doTrailer GlobalOpts {..} tidStr = do
+  for_ globalDb (setEnv "TASK_DB_PATH")
+  let tid = normalizeId (T.pack tidStr)
+  tasks <- loadTasks
+  case findTask tid tasks of
+    Nothing -> putText "Task not found"
+    Just task ->
+      let trailer = taskTrailer (taskId task)
+       in if globalJson
+            then outputJson <| Aeson.object ["taskId" Aeson..= taskId task, "trailer" Aeson..= trailer, "taskTrailer" Aeson..= trailer]
+            else putText trailer
+
+-- | patchset command
+patchsetParser :: Cli.Parser (IO ())
+patchsetParser =
+  doPatchset
+    </ globalOptsParser
+    <*> Cli.strArgument (Cli.metavar "ID" <> Cli.help "Task ID")
+    <*> Cli.switch (Cli.long "increment" <> Cli.help "Increment patchset count")
+
+doPatchset :: GlobalOpts -> String -> Bool -> IO ()
+doPatchset GlobalOpts {..} tidStr shouldIncrement = do
+  for_ globalDb (setEnv "TASK_DB_PATH")
+  let tid = normalizeId (T.pack tidStr)
+  tasks <- loadTasks
+  case findTask tid tasks of
+    Nothing -> putText "Task not found"
+    Just task -> do
+      count <-
+        if shouldIncrement
+          then fromMaybe (taskPatchsetCount task) </ incrementTaskPatchsetCount (taskId task)
+          else pure (taskPatchsetCount task)
+      if globalJson
+        then
+          outputJson
+            <| Aeson.object
+              [ "taskId" Aeson..= taskId task,
+                "patchsetCount" Aeson..= count,
+                "patchset_count" Aeson..= count,
+                "incremented" Aeson..= shouldIncrement,
+                "trailer" Aeson..= taskTrailer (taskId task)
+              ]
+        else
+          putText
+            <| "Patchset "
+            <> taskId task
+            <> ": "
+            <> T.pack (show count)
+            <> if shouldIncrement then " (incremented)" else ""
+
 -- | update command
 updateParser :: Cli.Parser (IO ())
 updateParser =
@@ -628,6 +688,26 @@ doImport GlobalOpts {..} file = do
 -- Helper Functions
 -- ============================================================================
 
+-- | Add computed trailer fields to a task JSON object.
+taskJsonWithTrailer :: Task -> Aeson.Value
+taskJsonWithTrailer task =
+  case Aeson.toJSON task of
+    Aeson.Object obj ->
+      Aeson.Object
+        <| KM.insert
+          "patchset_count"
+          (Aeson.Number (fromIntegral (taskPatchsetCount task)))
+          ( KM.insert
+              "trailer"
+              (Aeson.String (taskTrailer (taskId task)))
+              ( KM.insert
+                  "taskTrailer"
+                  (Aeson.String (taskTrailer (taskId task)))
+                  obj
+              )
+          )
+    other -> other
+
 -- | Output JSON
 outputJson :: (Aeson.ToJSON a) => a -> IO ()
 outputJson val = BLC.putStrLn <| Aeson.encode val
@@ -907,6 +987,7 @@ unitTests =
                   taskParent = Just (taskId parent),
                   taskNamespace = Nothing,
                   taskStatus = Open,
+                  taskPatchsetCount = 0,
                   taskPriority = P2,
                   taskComplexity = Nothing,
                   taskDependencies = [],
@@ -974,7 +1055,7 @@ unitTests =
       Test.unit "can create task with lowercase ID" <| do
         -- This verifies that lowercase IDs are accepted and not rejected
         let lowerId = "t-lowercase"
-        let task = Task lowerId "Lower" WorkTask Nothing Nothing Open P2 Nothing [] "Lower description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
+        let task = Task lowerId "Lower" WorkTask Nothing Nothing Open 0 P2 Nothing [] "Lower description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
         saveTask task
         tasks <- loadTasks
         case findTask lowerId tasks of
@@ -982,7 +1063,7 @@ unitTests =
           Nothing -> Test.assertFailure "Should find task with lowercase ID",
       Test.unit "generateId produces valid ID" <| do
         tid <- generateId
-        let task = Task tid "Auto" WorkTask Nothing Nothing Open P2 Nothing [] "Auto description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
+        let task = Task tid "Auto" WorkTask Nothing Nothing Open 0 P2 Nothing [] "Auto description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
         saveTask task
         tasks <- loadTasks
         case findTask tid tasks of
@@ -1006,11 +1087,11 @@ unitTests =
       Test.unit "lowercase ID does not clash with existing uppercase ID" <| do
         -- Setup: Create task with Uppercase ID
         let upperId = "t-UPPER"
-        let task1 = Task upperId "Upper Task" WorkTask Nothing Nothing Open P2 Nothing [] "Upper description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
+        let task1 = Task upperId "Upper Task" WorkTask Nothing Nothing Open 0 P2 Nothing [] "Upper description" [] (Tags []) (read "2025-01-01 00:00:00 UTC") (read "2025-01-01 00:00:00 UTC")
         saveTask task1
 
         let lowerId = "t-upper"
-        let task2 = Task lowerId "Lower Task" WorkTask Nothing Nothing Open P2 Nothing [] "Lower description" [] (Tags []) (read "2025-01-01 00:00:01 UTC") (read "2025-01-01 00:00:01 UTC")
+        let task2 = Task lowerId "Lower Task" WorkTask Nothing Nothing Open 0 P2 Nothing [] "Lower description" [] (Tags []) (read "2025-01-01 00:00:01 UTC") (read "2025-01-01 00:00:01 UTC")
         saveTask task2
 
         tasks <- loadTasks
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index efbf5783..1995c3e3 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -36,6 +36,7 @@ data Task = Task
     taskParent :: Maybe Text, -- Parent epic ID
     taskNamespace :: Maybe Text, -- Optional namespace (e.g., "Omni/Task", "Biz/Cloud")
     taskStatus :: Status,
+    taskPatchsetCount :: Int, -- Incremented each time a new patchset is produced
     taskPriority :: Priority, -- Priority level (0-4)
     taskComplexity :: Maybe Int, -- Complexity 1-5 for model selection
     taskDependencies :: [Dependency], -- List of dependencies with types
@@ -180,9 +181,45 @@ instance ToJSON Dependency
 
 instance FromJSON Dependency
 
-instance ToJSON Task
+instance ToJSON Task where
+  toJSON task =
+    Aeson.object
+      [ "taskId" Aeson..= taskId task,
+        "taskTitle" Aeson..= taskTitle task,
+        "taskType" Aeson..= taskType task,
+        "taskParent" Aeson..= taskParent task,
+        "taskNamespace" Aeson..= taskNamespace task,
+        "taskStatus" Aeson..= taskStatus task,
+        "taskPatchsetCount" Aeson..= taskPatchsetCount task,
+        "taskPriority" Aeson..= taskPriority task,
+        "taskComplexity" Aeson..= taskComplexity task,
+        "taskDependencies" Aeson..= taskDependencies task,
+        "taskDescription" Aeson..= taskDescription task,
+        "taskComments" Aeson..= taskComments task,
+        "taskTags" Aeson..= taskTags task,
+        "taskCreatedAt" Aeson..= taskCreatedAt task,
+        "taskUpdatedAt" Aeson..= taskUpdatedAt task
+      ]
 
-instance FromJSON Task
+instance FromJSON Task where
+  parseJSON =
+    Aeson.withObject "Task" <| \obj ->
+      Task
+        </ (obj Aeson..: "taskId")
+        <*> (obj Aeson..: "taskTitle")
+        <*> (obj Aeson..: "taskType")
+        <*> (obj Aeson..: "taskParent")
+        <*> (obj Aeson..: "taskNamespace")
+        <*> (obj Aeson..: "taskStatus")
+        <*> (fromMaybe 0 </ (obj Aeson..:? "taskPatchsetCount"))
+        <*> (obj Aeson..: "taskPriority")
+        <*> (obj Aeson..: "taskComplexity")
+        <*> (obj Aeson..: "taskDependencies")
+        <*> (obj Aeson..: "taskDescription")
+        <*> (obj Aeson..: "taskComments")
+        <*> (obj Aeson..: "taskTags")
+        <*> (obj Aeson..: "taskCreatedAt")
+        <*> (obj Aeson..: "taskUpdatedAt")
 
 instance ToJSON TaskProgress
 
@@ -340,7 +377,7 @@ instance ToJSON Tags where
   toJSON (Tags ts) = Aeson.toJSON ts
 
 instance FromJSON Tags where
-  parseJSON v = Tags <$> Aeson.parseJSON v
+  parseJSON v = Tags </ Aeson.parseJSON v
 
 instance SQL.FromField Tags where
   fromField f = do
@@ -363,6 +400,7 @@ instance SQL.FromRow Task where
       <*> SQL.field
       <*> SQL.field
       <*> SQL.field
+      <*> SQL.field -- patchset_count
       <*> SQL.field
       <*> SQL.field -- complexity
       <*> SQL.field
@@ -380,6 +418,7 @@ instance SQL.ToRow Task where
       SQL.toField (taskParent t),
       SQL.toField (taskNamespace t),
       SQL.toField (taskStatus t),
+      SQL.toField (taskPatchsetCount t),
       SQL.toField (taskPriority t),
       SQL.toField (taskComplexity t),
       SQL.toField (taskDependencies t),
@@ -460,6 +499,10 @@ matchesId id1 id2 = normalizeId id1 == normalizeId id2
 normalizeId :: Text -> Text
 normalizeId = T.toLower
 
+-- | Render the canonical git trailer for a task ID.
+taskTrailer :: Text -> Text
+taskTrailer tid = "Task-Id: " <> normalizeId tid
+
 -- | Find a task by ID (case-insensitive)
 findTask :: Text -> [Task] -> Maybe Task
 findTask tid = List.find (\t -> matchesId (taskId t) tid)
@@ -499,64 +542,69 @@ getTasksDbPath = do
 withDb :: (SQL.Connection -> IO a) -> IO a
 withDb action = do
   dbPath <- getTasksDbPath
+  createDirectoryIfMissing True (takeDirectory dbPath)
   SQL.withConnection dbPath <| \conn -> do
     SQL.execute_ conn "PRAGMA busy_timeout = 5000"
+    ensureSchema conn
     action conn
 
 -- Initialize the task database
 initTaskDb :: IO ()
 initTaskDb = do
-  dbPath <- getTasksDbPath
-  createDirectoryIfMissing True (takeDirectory dbPath)
-  withDb <| \conn -> do
-    SQL.execute_
-      conn
-      "CREATE TABLE IF NOT EXISTS tasks (\
-      \ id TEXT PRIMARY KEY, \
-      \ title TEXT NOT NULL, \
-      \ type TEXT NOT NULL, \
-      \ parent TEXT, \
-      \ namespace TEXT, \
-      \ status TEXT NOT NULL, \
-      \ priority TEXT NOT NULL, \
-      \ complexity INTEGER, \
-      \ dependencies TEXT NOT NULL, \
-      \ description TEXT, \
-      \ comments TEXT NOT NULL DEFAULT '[]', \
-      \ tags TEXT NOT NULL DEFAULT '[]', \
-      \ created_at TIMESTAMP NOT NULL, \
-      \ updated_at TIMESTAMP NOT NULL \
-      \)"
-    SQL.execute_
-      conn
-      "CREATE TABLE IF NOT EXISTS id_counter (\
-      \ id INTEGER PRIMARY KEY CHECK (id = 1), \
-      \ counter INTEGER NOT NULL DEFAULT 0 \
-      \)"
-    SQL.execute_
-      conn
-      "INSERT OR IGNORE INTO id_counter (id, counter) VALUES (1, 0)"
-    SQL.execute_
-      conn
-      "CREATE TABLE IF NOT EXISTS retry_context (\
-      \ task_id TEXT PRIMARY KEY, \
-      \ original_commit TEXT NOT NULL, \
-      \ conflict_files TEXT NOT NULL, \
-      \ attempt INTEGER NOT NULL DEFAULT 1, \
-      \ reason TEXT NOT NULL \
-      \)"
-    SQL.execute_
-      conn
-      "CREATE TABLE IF NOT EXISTS facts (\
-      \ id INTEGER PRIMARY KEY AUTOINCREMENT, \
-      \ project TEXT NOT NULL, \
-      \ fact TEXT NOT NULL, \
-      \ related_files TEXT NOT NULL, \
-      \ source_task TEXT, \
-      \ confidence REAL NOT NULL, \
-      \ created_at DATETIME DEFAULT CURRENT_TIMESTAMP \
-      \)"
-    runMigrations conn
+  withDb <| \_ -> pure ()
+
+-- | Ensure required base tables exist, then run migrations.
+ensureSchema :: SQL.Connection -> IO ()
+ensureSchema conn = do
+  SQL.execute_
+    conn
+    "CREATE TABLE IF NOT EXISTS tasks (\
+    \ id TEXT PRIMARY KEY, \
+    \ title TEXT NOT NULL, \
+    \ type TEXT NOT NULL, \
+    \ parent TEXT, \
+    \ namespace TEXT, \
+    \ status TEXT NOT NULL, \
+    \ patchset_count INTEGER NOT NULL DEFAULT 0, \
+    \ priority TEXT NOT NULL, \
+    \ complexity INTEGER, \
+    \ dependencies TEXT NOT NULL, \
+    \ description TEXT, \
+    \ comments TEXT NOT NULL DEFAULT '[]', \
+    \ tags TEXT NOT NULL DEFAULT '[]', \
+    \ created_at TIMESTAMP NOT NULL, \
+    \ updated_at TIMESTAMP NOT NULL \
+    \)"
+  SQL.execute_
+    conn
+    "CREATE TABLE IF NOT EXISTS id_counter (\
+    \ id INTEGER PRIMARY KEY CHECK (id = 1), \
+    \ counter INTEGER NOT NULL DEFAULT 0 \
+    \)"
+  SQL.execute_
+    conn
+    "INSERT OR IGNORE INTO id_counter (id, counter) VALUES (1, 0)"
+  SQL.execute_
+    conn
+    "CREATE TABLE IF NOT EXISTS retry_context (\
+    \ task_id TEXT PRIMARY KEY, \
+    \ original_commit TEXT NOT NULL, \
+    \ conflict_files TEXT NOT NULL, \
+    \ attempt INTEGER NOT NULL DEFAULT 1, \
+    \ reason TEXT NOT NULL \
+    \)"
+  SQL.execute_
+    conn
+    "CREATE TABLE IF NOT EXISTS facts (\
+    \ id INTEGER PRIMARY KEY AUTOINCREMENT, \
+    \ project TEXT NOT NULL, \
+    \ fact TEXT NOT NULL, \
+    \ related_files TEXT NOT NULL, \
+    \ source_task TEXT, \
+    \ confidence REAL NOT NULL, \
+    \ created_at DATETIME DEFAULT CURRENT_TIMESTAMP \
+    \)"
+  runMigrations conn
 
 -- | Run schema migrations to add missing columns to existing tables
 runMigrations :: SQL.Connection -> IO ()
@@ -597,6 +645,7 @@ tasksColumns =
     ("parent", "TEXT"),
     ("namespace", "TEXT"),
     ("status", "TEXT"),
+    ("patchset_count", "INTEGER NOT NULL DEFAULT 0"),
     ("priority", "TEXT"),
     ("complexity", "INTEGER"),
     ("dependencies", "TEXT"),
@@ -699,7 +748,7 @@ getSuffix parent childId =
 loadTasks :: IO [Task]
 loadTasks =
   withDb <| \conn -> do
-    SQL.query_ conn "SELECT id, title, type, parent, namespace, status, priority, complexity, dependencies, description, comments, tags, created_at, updated_at FROM tasks"
+    SQL.query_ conn "SELECT id, title, type, parent, namespace, status, patchset_count, priority, complexity, dependencies, description, comments, tags, created_at, updated_at FROM tasks"
 
 -- Save a single task (UPSERT)
 saveTask :: Task -> IO ()
@@ -708,8 +757,8 @@ saveTask task =
     SQL.execute
       conn
       "INSERT OR REPLACE INTO tasks \
-      \ (id, title, type, parent, namespace, status, priority, complexity, dependencies, description, comments, tags, created_at, updated_at) \
-      \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+      \ (id, title, type, parent, namespace, status, patchset_count, priority, complexity, dependencies, description, comments, tags, created_at, updated_at) \
+      \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
       task
 
 -- Create a new task
@@ -733,6 +782,7 @@ createTask title taskType parent namespace priority complexity deps description
               taskParent = parent',
               taskNamespace = namespace,
               taskStatus = Open,
+              taskPatchsetCount = 0,
               taskPriority = priority,
               taskComplexity = complexity,
               taskDependencies = deps',
@@ -787,6 +837,26 @@ updateTaskStatusWithActor tid newStatus newDeps actor =
           sessionId <- getOrCreateCommentSession tid
           insertAgentEvent tid sessionId "status_change" content actor
 
+-- | Increment patchset_count for a task and return the new value.
+incrementTaskPatchsetCount :: Text -> IO (Maybe Int)
+incrementTaskPatchsetCount tid =
+  withTaskLock <| do
+    now <- getCurrentTime
+    withDb <| \conn -> do
+      SQL.execute
+        conn
+        "UPDATE tasks SET patchset_count = COALESCE(patchset_count, 0) + 1, updated_at = ? WHERE id = ?"
+        (now, tid)
+      rows <- SQL.query conn "SELECT patchset_count FROM tasks WHERE id = ?" (SQL.Only tid) :: IO [SQL.Only Int]
+      pure <| listToMaybe (map SQL.fromOnly rows)
+
+-- | Get the current patchset_count for a task.
+getTaskPatchsetCount :: Text -> IO (Maybe Int)
+getTaskPatchsetCount tid =
+  withDb <| \conn -> do
+    rows <- SQL.query conn "SELECT patchset_count FROM tasks WHERE id = ?" (SQL.Only tid) :: IO [SQL.Only Int]
+    pure <| listToMaybe (map SQL.fromOnly rows)
+
 -- Edit a task
 editTask :: Text -> (Task -> Task) -> IO Task
 editTask tid modifyFn =
@@ -1098,6 +1168,8 @@ showTaskDetailed t = do
   putText <| "Title:      " <> taskTitle t <> " (ID: " <> taskId t <> ")"
   putText <| "Type:       " <> T.pack (show (taskType t))
   putText <| "Status:     " <> T.pack (show (taskStatus t))
+  putText <| "Patchset:   " <> T.pack (show (taskPatchsetCount t))
+  putText <| "Trailer:    " <> taskTrailer (taskId t)
   putText <| "Priority:   " <> T.pack (show (taskPriority t)) <> priorityDesc
   case taskComplexity t of
     Nothing -> pure ()
diff --git a/Omni/Task/MigrationTest.hs b/Omni/Task/MigrationTest.hs
index ee8033b7..2fc7b9a9 100644
--- a/Omni/Task/MigrationTest.hs
+++ b/Omni/Task/MigrationTest.hs
@@ -29,7 +29,7 @@ migrationStartupTest =
       eventsCols <- getTableColumns conn "agent_events"
       retryCols <- getTableColumns conn "retry_context"
 
-      Set.fromList ["id", "title", "status"]
+      Set.fromList ["id", "title", "status", "patchset_count"]
         `Set.isSubsetOf` Set.fromList tasksCols
         Test.@?= True
       Set.fromList ["id", "task_id", "event_type", "actor"]