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"]