Skip to content

feat(taskctl): Phase 3b — Developer + adversarial pipeline: verdict tool, retry loop, @ops commit #205

@randomm

Description

@randomm

Context

Part of the taskctl epic (#201). Depends on Phase 3a (#204).

What you're building: The full inner loop — the developer writes code, adversarial reviews it and writes a structured JSON verdict to the task store, Pulse reads the verdict and either commits the work (if approved) or sends the developer back to fix issues. You're also updating the developer agent's system prompt to remove the things it no longer needs to do (commit, spawn adversarial), and adding the verdict sub-command to the taskctl tool.

Background reading

Read lievo/plan-v2.md sections: "Developer", "Adversarial", "@ops", and "Phase 3" under Implementation Phases.

Also read the existing developer and adversarial prompts in ~/.config/opencode/ to understand what currently exists and what needs to change.

What changes in the developer agent prompt

The developer agent currently:

  • Spawns adversarial internally (REMOVE THIS)
  • Manages git commits (REMOVE THIS)
  • Returns "Adversarial: APPROVED" text to PM (REMOVE THIS)

The developer agent after this change:

  • Implements the task in its worktree as before (TDD, typecheck, bun test — keep all of this)
  • Calls taskctl comment <taskId> "Implementation complete: <brief summary>" when done
  • Can call taskctl comment <taskId> "..." at any point to log progress
  • Can call taskctl split <taskId> --into "..." "..." if task is too big
  • Can call taskctl depends <taskId> --on <otherId> if it discovers an undeclared dependency
  • Does NOT know about adversarial review. Does NOT know about commits. Just implements.

The developer's task details (title, description, acceptance criteria) should be passed as part of the prompt when Pulse spawns it. In Phase 3a, Pulse passes a basic prompt — update that here to include the full task details and the updated instructions.

The adversarial verdict tool

Add a new sub-command to taskctl (in tool.ts):

taskctl verdict <taskId> --verdict APPROVED
taskctl verdict <taskId> --verdict ISSUES_FOUND --issues '[{"location":"src/auth.ts:42","severity":"HIGH","fix":"Add null check"}]' --summary "Missing null check before user lookup"
taskctl verdict <taskId> --verdict CRITICAL_ISSUES_FOUND --issues '[...]' --summary "..."

This command:

  1. Validates that the calling agent has adversarial permission (only adversarial can call verdict)
  2. Parses the JSON --issues argument
  3. Writes the verdict to task.pipeline.adversarial_verdict in the task store
  4. Updates task.status to "review" (if not already) to signal Pulse that a verdict is waiting
  5. Adds a comment to the task summarising the verdict

The adversarial agent itself is largely unchanged in terms of what it looks for. What changes is its output: instead of writing a long text response, it calls taskctl verdict with structured data. Update the adversarial agent prompt to explain this.

processAdversarialVerdicts in Pulse

Add this function to Pulse (in the 5-second tick, between heartbeatActiveAgents and scheduleReadyTasks):

async function processAdversarialVerdicts(jobId: string, projectId: string) {
  const tasks = await getTasksForJob(jobId, projectId)
  for (const task of tasks) {
    if (task.status !== "review") continue
    if (!task.pipeline.adversarial_verdict) continue

    const verdict = task.pipeline.adversarial_verdict
    if (verdict.verdict === "APPROVED") {
      await commitTask(task, projectId)     // calls @ops
    } else {
      // ISSUES_FOUND or CRITICAL_ISSUES_FOUND
      task.pipeline.attempt++
      if (task.pipeline.attempt >= 3) {
        await escalateToPM(task, jobId)    // notify PM, mark as failed
      } else {
        await respawnDeveloper(task, jobId, projectId)  // send back with feedback
      }
    }
    // Clear the verdict so we don't process it again
    task.pipeline.adversarial_verdict = null
    await writeTask(task, projectId)
  }
}

commitTask — calling @ops

When adversarial approves, Pulse needs to commit the worktree to the feature branch. Do this by spawning the existing ops agent (same way Pulse spawns developer agents) with a clear, specific prompt:

Commit all changes in worktree at <worktree path> to branch <branch name>.
Commit message: "feat(<scope>): <task title> (#<parent_issue>)"
Do not push to remote. Only commit locally.

Wait for @ops to complete (use sync spawning — look at how sync: true works in the task tool).

If @ops succeeds:

  • Set task status to "closed", pipeline.stage to "done"
  • Remove the worktree using Worktree.remove()
  • Add a comment: "Committed to <branch> by @ops"
  • Fire a BackgroundTaskEvent notification to PM for this individual task completion (incremental notification)

If @ops fails with a merge conflict:

  • Set task status to "blocked_on_conflict"
  • Attempt automatic rebase: run git rebase origin/<branch> in the worktree
  • If rebase succeeds: retry @ops commit (once)
  • If rebase fails: escalate to PM with the conflict details, keep worktree intact

respawnDeveloper — retry with feedback

When adversarial finds issues but we haven't hit the 3-attempt limit:

  1. Kill the current developer session (it's already done, but clean up)
  2. Spawn a new developer session in the SAME worktree (don't recreate it — the partial work is still there)
  3. Pass the new developer a prompt that includes:
    • Original task description and acceptance criteria
    • The adversarial feedback: task.pipeline.adversarial_verdict.issues with locations and fixes
    • Instruction: "The previous implementation had issues. Fix them. Here is the feedback: ..."4. Update task.assignee with the new session ID

escalateToMP — 3 strikes

When pipeline.attempt >= 3:

  1. Set task status to "failed"
  2. Keep worktree intact (PM may want to inspect it)
  3. Fire a push notification to PM (via BackgroundTaskEvent):
    [System: Pipeline escalation — Job #<issueNum>]
    Task <taskId> failed after 3 adversarial review cycles.
    Last issue: <adversarial_verdict.summary>
    → taskctl inspect <taskId>
    → taskctl override <taskId> --skip
    → taskctl retry <taskId>
    

When Pulse spawns the adversarial agent

After a developer signals completion (taskctl comment <id> "Implementation complete..."), Pulse should detect this on the next tick via heartbeatActiveAgents — specifically, when it sees the developer session has ended (no longer live). At that point:

  1. Set task status from in_progress to review, pipeline.stage to "reviewing"
  2. Spawn the adversarial agent with a prompt that includes:
    • Task title, description, acceptance criteria
    • The worktree path (so adversarial can read the files with the Read tool)
    • Instruction: "Review the code changes in this worktree. Use taskctl verdict to record your finding."

Tool permission wiring

Now that Pulse is managing agent spawning, wire up tool permission restrictions:

When spawning developer via SessionPrompt.prompt(), pass:

tools: {
  taskctl_start: false,
  taskctl_stop: false,
  taskctl_resume: false,
  taskctl_status: false,
  taskctl_inspect: false,
  taskctl_create: false,
  taskctl_validate: false,
  taskctl_override: false,
  taskctl_retry: false,
  taskctl_verdict: false,  // developer cannot write verdicts
}

When spawning adversarial, pass:

tools: {
  // adversarial can ONLY call taskctl verdict — everything else denied
  // implement this by checking the sub-command inside tool.ts execute()
}

The cleanest approach: in tool.ts, when routing sub-commands, check ctx.agent (the current agent name) against the permission table from lievo/plan-v2.md. If the agent isn't allowed to call that sub-command, return an error.

Testing

packages/opencode/test/tasks/pipeline.test.ts

Mock Session.createNext(), SessionPrompt.prompt(), and the @ops call. Test:

  • APPROVED verdict → @ops commit called → task closed → PM notified
  • ISSUES_FOUND → developer respawned with feedback → attempt counter incremented
  • 3rd consecutive CRITICAL → task marked failed → PM escalation fired
  • Merge conflict → rebase attempted → on success retry commit → on failure PM escalated
  • Developer session ending detected → adversarial spawned
  • Adversarial permission: verdict only allowed for adversarial agent

Run bun test and bun run typecheck — both must pass.

Acceptance criteria

  • Developer agent prompt updated: no adversarial spawning, no git commit, uses taskctl comment to signal done
  • Adversarial agent prompt updated: uses taskctl verdict tool instead of text response
  • taskctl verdict sub-command exists and writes structured result to task store
  • processAdversarialVerdicts() in Pulse reads verdicts and routes: commit / retry / escalate
  • @ops is called to commit approved work; success → task closed + PM notified
  • Merge conflict on commit → rebase attempted → escalated to PM if unresolvable
  • Developer respawned with adversarial feedback when issues found (up to 3 attempts)
  • 3rd failure → task marked failed + PM escalation notification sent
  • Tool permission restrictions enforced: developer cannot call start/verdict, adversarial can only call verdict
  • bun test passes, bun run typecheck passes with 0 errors

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions