vincent 0.12.0 is out, 33 days after 0.11.0. It is the largest release so far, and the first one built on the refactor I described in the three-pillar post: work comes in through intake, gets done in the workshop, and leaves through a handoff. A month ago that was a plan with diagrams. Now it is the shape of the product.
For anyone new here: vincent is a local, vendor-independent control plane for coding agents. It runs Claude Code, Codex, Cursor and now GitHub Copilot CLI as they ship, gives every task its own branch and worktree, drives them through YAML workflows you can read, and keeps the state, the gates and the verification on your own machine. 0.9.0 gave it triggers and pull request actions, 0.10 added skills and @file mentions in chat and tasks on an existing branch, and 0.11 rebuilt the TUI around an Overview tab with failure and outcome cards.

Between the two tags there are 765 commits and 73 merged pull requests, and 64 of those pull requests came from vincent/ task branches: vincent opened them while working on itself. The 0.12.0 changelog has the condensed list. This post walks through each piece with real commands and YAML, then strings them together into one engineering workflow from a GitHub issue to a merged pull request.
How the pillars landed
The plan named three pillars: intake (where work comes from), workshop (where it gets done) and handoff (how it leaves). In 0.12.0 each one became something concrete:
- Intake: an issue tracker inside vincent. Issues can be filed locally, and on a GitHub project they are imported and kept in sync.
- Workshop: still tasks and workflows, but tasks now belong to an issue, and an issue’s tasks share one branch and one worktree.
- Handoff: a new object. A finished task records what it delivered, and a handoff claims a batch of finished issues and runs one workflow over them that opens pull requests, merges, comments or closes.
The extraction I argued for happened the way I wanted. My own github-resolve-issue.yaml no longer calls gh issue view at all: its fetch step copies the snapshot the daemon wrote before the step started, and every lane of the DAG variant copies its parent’s snapshot instead of fetching the issue again. Its tail changed too. The run used to go on past verification into a push, gh pr create, a second gate and a rebase-and-merge loop. Now it ends at a step called deliver that records a verified branch, and nothing on its step path reaches GitHub any more. The landing half moved into a separate handoff workflow, github-handoff-train, which I come back to below.
Some things came out differently from the plan. I expected one handoff per work item, and it became a batch: one integration task over several issues, because that is how a merge train wants to work. The workshop also gained something the plan never mentioned, the idea of one line of work per issue. And grants, the rule that a step cannot write to GitHub without a human’s consent, were not in the plan at all. They turned out to be the piece that makes the handoff safe to automate.
One gap from the plan is closed only halfway. The pillars post complained that a rejected or under-specified issue just failed the task with nothing written back. 0.12.0 has the mechanism for that, a reply delivery and a handoff that posts it as a comment, but my own github-resolve-issue.yaml still ends a reject or needs-info verdict as a failed task. Wiring that up is still on my list.
Issues vincent owns
Issues are now vincent’s own records, with a title, a Markdown body, labels, a kind, a priority, a comment thread and a state. You can file, edit, label, comment on, close, reopen and delete them from the TUI, from vincent issue, from the API or from the MCP server. A project with no GitHub remote gets a working tracker too.
Filing one from the shell, or piping the body in from wherever it lives:
vincent issue add --project 1 --title "Crash on cold start" --label bug --kind bug --priority 2
gh issue view 200 --json body --jq .body | vincent issue add --project 1 --title "Crash on cold start" --body-file -
On a project whose origin is a github.com repository, the daemon’s reconciler imports the issues and refreshes them on every github.poll_interval tick, open ones first and then incrementally. Comment threads are mirrored, and a person closing or reopening an imported issue in vincent is written back to GitHub. Title, body and labels of an imported issue stay GitHub’s, so the TUI shows them as mirrored and refuses to edit them, while kind and priority are vincent’s own and save as usual. Deleting an issue in vincent never deletes it on GitHub, and a tombstone stops the next sync from importing it again. In the other direction, an issue GitHub reports as gone is deleted from vincent unless a live task still holds it.
vincent issue sync asks for an import now and prints how the import stands:
vincent issue sync --project 1
vincent issue sync --project 1 --status
The issue detail shows everything vincent knows: lane, labels, kind, priority, the source and its last sync, the description rendered as Markdown, the mirrored thread, and which task currently holds the issue’s worktree:

The issues board
The issues screen lays out the selected project’s issues in four lanes: open, in progress, hand-off and done. No key moves a card between lanes. Vincent derives the lane from the issue and its root tasks: starting a task moves an issue to in progress, finishing it with a ready delivery moves it to hand-off, and closing it moves it to done.

A ! beside a card means one of its root tasks is waiting on you: awaiting input, awaiting a gate, or blocked. It shows on any lane, so a closed issue with a stuck task still gets your attention. s shows or hides the done lane, ← and → fold the section you are in, and / filters by id, title, label or kind. R re-reads what vincent already holds, while S asks the daemon to sync the project from GitHub right now, which also sweeps closed and deleted issues that the reconciler otherwise checks once a day.
The same lanes are available from the shell:
vincent issue ls --project 1 --lane hand_off
vincent issue ls --project 1 --handoff pending
Lanes are also events. The issues trigger source fires on opened, closed, reopened, labeled, unlabeled, and, if a trigger opts in, lane_changed and handoff_changed. Because it reads vincent’s own record of issue changes, it works on a local issue and an imported one the same way.
Starting work from an issue
a on an issue opens the new-task form already filled from it: title, description, and any of the workflow’s declared fields named issue, github_issue, labels, assignee, milestone or kind. Everything stays editable before you create the task. From the shell it is --issue, which carries the vincent issue id and nothing else:
vincent task add --project 1 --issue 7
The task gets the issue as a snapshot taken at creation, in two forms. Templates read .Issue, including the full comment thread. Command steps read $VINCENT_ISSUE_FILE, a JSON file in the same shape gh issue view --json prints, so jq written for gh reads it unchanged. The snapshot is written from the task and never fetched, so a step cannot fail because GitHub is down, and a fan-out lane inherits its parent’s.
One detail matters when you migrate templates: .Issue.Number is now the vincent issue id. The GitHub number of an imported issue lives in .Issue.Source.Number. Here is a small workflow that uses both forms. I validated it with vincent workflow validate on the 0.12.0 binary, and it is the one the walkthrough below runs:
name: fix-issue
description: Fix the issue this task was started from, prove it with a test, and record the delivery.
platforms: [posix]
defaults:
agent: claude
max_retries: 1
steps:
- id: guard
type: command
run: |
[ -n "${VINCENT_ISSUE_FILE:-}" ] || { echo "start this task from an issue" >&2; exit 1; }
jq -e '.state == "OPEN"' "$VINCENT_ISSUE_FILE"
- id: fix
type: agent
prompt: |
Fix this issue: {{ .Issue.Title }}
{{ .Issue.Body }}
The discussion so far:
{{ range .Issue.Comments }}{{ .Author }} wrote: {{ .Body }}
{{ end }}
Add a test that fails without the fix, then make it pass.
check: go test ./...
- id: commit
type: command
run: 'git add -A && { git diff --cached --quiet || git commit -m "fix: issue {{ .Issue.Number }}"; }'
- id: deliver
type: command
run: |
set -e
body=$(git rev-parse --git-path vincent-delivery.md)
printf 'Fixed with a regression test.\n\nCloses #%s\n' {{ .Issue.Source.Number }} > "$body"
vincent task deliver --title "Fix issue {{ .Issue.Number }}" --body-file "$body"
The free text of the issue (title, body, comments) only ever reaches the agent’s prompt. The run: bodies template nothing but numbers, which the docs call out as the safe case: an issue title rendered into a shell line is attacker-controlled shell.
One line of work per issue
This is the workshop change I use most. The first task created from an issue is its main task, and its branch becomes the issue’s main branch. Every later main task runs on that same branch, in the same directory, one at a time. When the next one is admitted, the previous task hands over the worktree as it stands, uncommitted changes included, and the new task’s base_sha is the branch tip at that moment, so each task’s diff shows only its own work.
A main task created while another one holds the worktree is queued, never refused, and the CLI says so:
queued behind #61 (main worktree busy); pass --separate-worktree to run now
task 62 created: Crash on cold start (adhoc, branch vincent/61-crash-on-cold-start)
from issue 7: Crash on cold start
When something needs doing beside the main line, a side task runs now in its own worktree, cut from the issue’s main branch, and merges itself back when it finishes:
vincent task add --project 1 --issue 7 --separate-worktree --merge agent
The merge-back is a task of its own, titled Merge task {side} into issue #{issue}, that queues for the main worktree like any main task and merges the side branch with --no-ff. --merge block, the default, stops and blocks on a conflict with the main worktree left mid-merge, so you can resolve it there and retry. --merge agent first runs a built-in resolver with the side task’s agent, model and effort. If that fails, or leaves conflict markers behind, the merge-back blocks exactly as block would. Related to that: 0.12.0 also fixes an agent’s leftover conflict markers being committed, and two tasks on one existing branch no longer start together.
On the board an in-progress card reads ● #61 running for the occupant, with · +k side while side tasks run and · n merging while merge-backs wait. The issue detail has a Main worktree section naming the branch and the task holding it.
A chat on the issue
T on an issue opens a chat that works the issue’s main line itself, in the main worktree. No form opens: the issue decides the project, worktree and branch, and the chat’s first turn carries the issue’s title, description and comments. The shell equivalent is:
vincent chat start "Reproduce the cold start crash" --project 1 --issue 7
The chat is an occupant like any main task. It cannot open while a main task is working in the worktree, and a main task created while the chat holds it waits queued. It ends one of two ways from its workspace: ctrl+q closes it and leaves the directory as it stands for the next main task, and ctrl+t hands it off into the issue’s next main task on the same worktree. Either way the branch stays the issue’s main branch. This is where I do the exploratory part now: talk the problem through with the full thread in context, then let a workflow take over from exactly where the conversation left the files.
Handoffs, deliveries, and grants
This is the closure pillar, and the handoff guide is the long version of this section.
It starts with a delivery: what a task’s run produced. Its kind is change (commits to integrate), reply (an answer, a refusal or questions to post back where the work came from) or none. A step records one with vincent task deliver, and a task that finishes done without recording one gets an implicit delivery, change when its branch holds commits its base does not and none otherwise.
Whether a finished delivery is ready to hand off is the workflow’s hand_off: key. auto, the default, marks it ready when the task ends done, so the issue moves to hand-off as soon as nothing of it is running. explicit leaves it in progress until a step runs vincent task deliver --ready or a person runs vincent issue ready, which suits spikes and research whose result a person should judge first:
hand_off: explicit # or: auto, the default
A handoff runs a workflow that declares role: handoff. Nothing else may run such a workflow, and a handoff runs nothing else. You can name the issues or let the handoff select them:
vincent handoff start --project 1 --workflow merge-train 12 14 15
vincent handoff start --project 1 --workflow merge-train --delivery change --label ready-to-ship --max 8
The handoff claims every issue it took, and the claim is exclusive: while it holds an issue, a new task, a chat hand-off or a follow-up on that issue is refused. Its task reads the items from .Handoff or $VINCENT_HANDOFF_FILE, each with the issue, its delivery, and the delivering task’s id, branch and verified head. Each item then ends with one of four verbs:
| Verb | What happens to the issue |
|---|---|
vincent handoff deliver | Closed with the delivery’s suggested reason (else completed), or left open with --close none |
vincent handoff return | Claim dropped, readiness withdrawn, optionally with a --follow-up prompt |
vincent handoff wait | Parked on something outside the handoff; a later close records it delivered |
vincent handoff release | Claim dropped with no verdict, eligible for a later handoff |
From the board, space selects a pending hand-off card, V selects all of them, and I opens the handoff form. The form asks for the role: handoff workflow, the grant, an optional title, and whether to start paused:

The grant is the safety rule that came out of this work. A workflow step that writes to GitHub through vincent (vincent github pr create, merge, close, reopen, comment or rerun, or a close, reopen or comment on an imported issue) is now refused with 409 forge_write_needs_human unless a grant covers it. A grant names capabilities from a closed list: issue.comment, issue.close, pull.create, pull.comment, pull.merge, pull.close and checks.rerun. It applies only to the handoff’s own task, only on its own items and their pull requests, and only a person can give one: on the command line with --grant, by ticking boxes in the TUI form, or by arming a trigger that carries a grant:. An agent may start a handoff, but one that asks for a grant is refused. The docs are honest about the limit: this keeps an agent’s honest call off GitHub, but it is not a security boundary against an agent running as you, and a step’s own gh pr call in its worktree is unaffected.
Once the handoff’s task is running, its Overview lists the items and where each one stands:

Four example handoff workflows ship with 0.12.0, each installed with vincent workflow init <name> --from <name> and paired with a trigger under docs/examples/triggers/ that ships with enabled: false:
merge-train: stacks every delivered change onto the handoff task’s branch withgit rebase --onto, verifies them together, opens one pull request, merges it pinned to the verified commit, then delivers every item. Its trigger runs at 02:00 on weekdays and grantspull.create,pull.mergeandissue.close.comment-and-close: takes thereplydeliveries, posts each body on its issue, and closes it, without a single agent session. Its trigger grantsissue.commentandissue.close.pr-per-issue: opens one pull request per item from the delivering task’s branch and marks each itemwait. Review and merge stay yours, and GitHub’sCloses #Nplus the sync record the item delivered.notify-handoff: posts the handoff file to a webhook and delivers every item with--close none. It writes nothing to GitHub, so its trigger grants nothing.
This is the pr-per-issue trigger as it ships. It fires each time a task moves an issue into hand-off:
id: pr-per-issue
enabled: false
source:
type: issues
project: 1
match:
action: lane_changed
lane: hand_off
by: task
action:
type: start_handoff
workflow: pr-per-issue
select: {lane: hand_off, delivery: change}
grant: [pull.create]
overrun: queue_coalesce
concurrency_key: pr-per-issue
overrun: queue_coalesce folds work that becomes ready while one handoff runs into the next one. Arming the file is the consent: switching enabled to true, or widening its grant:, counts as arming, and vincent trigger apply refuses an agent’s staged proposal that would do either.

The vincent repository lands its own work this way. github-handoff-train is the merge train plus an agent review of the whole train, a repair loop for its findings and the required checks, and a close of every issue through vincent. Its trigger runs on weekday nights with select: {lane: hand_off, delivery: change, max: 8} and grants pull.create, pull.merge, issue.comment and issue.close.
GitHub Copilot as the fourth agent
GitHub Copilot CLI joins Claude Code, Codex and Cursor as agent: copilot. That is the standalone copilot binary, not the retired gh copilot extension. Workflow steps and chats can run on it, with vincent’s MCP tools, resumed chat sessions, and @ file mentions, which copilot reads itself rather than vincent expanding them. The tested build is 1.0.105.
Restricted mode holds on macOS, Linux and Windows alike. Vincent passes --available-tools=view,grep,glob,edit,create,apply_patch, which leaves the shell tool out entirely, so a restricted copilot step has no shell and no git. The shipped copilot-review example is built around that:
name: copilot-review
description: Review the working tree with GitHub Copilot in restricted mode, write the findings to a file, and commit them.
defaults:
agent: copilot
max_retries: 1
Its review step runs permission_mode: restricted and writes REVIEW.md, and a separate command step commits it. A few copilot properties are worth knowing before you switch a workflow over. Copilot exposes no model catalog, so models are free text and the CLI picks auto when none is given. auto refuses any effort, so a step that sets effort: needs an explicit model:. And copilot reports tokens but no cost: each step spends premium requests, which vincent never shows as dollars, so max_task_cost_usd never counts a copilot run. The agents guide covers login, tokens and the MCP config in detail.
One project at a time
The TUI now works on one selected project. The header names it (◆ web), and @, a click on that segment, or “switch project” in the palette changes it. Boards, lists, new tasks, chats and issues all follow the selection, while tasks that need you in other projects are still counted in the header. vincent --project <name|id> picks the project at launch, and without it the TUI picks the project whose checkout or worktree you launched from, then tui.default_project, then the one you used last.
The Projects screen became a project overview, with the work in every project and a “Needs you, across projects” list:

Main tabs are new too. tui.tabs lists the screens the TUI opens on, by default the issues list and the task board, drawn in the header as Issues │ Tasks. tab and shift+tab or a click switch between them, and closing any other screen returns to the tab you came from:
tui:
tabs: [tasks, issues, chats]
The first entry is where the TUI opens. A single entry turns the strip off. With one project on screen the board’s project grouping level stopped making sense, so the board now groups by workflow by default and the project level is deprecated.
Smaller changes and fixes
-
Claude waits for background work: a claude chat turn or step no longer ends while a background command it started (a test suite, a CI watch) is still running. Claude Code wakes the agent when that work finishes, but only while its process is alive, so vincent keeps the run open for up to
defaults.background_wait, 30 minutes by default, until the agent answers.0restores the old behaviour. Only the claude adapter has background work. -
Versioned docs: the documentation site is versioned now. It shows the latest release by default, with every release since v0.6.0 and the unreleased docs a selector away. A privacy policy is published too.
-
Small terminals: popups, forms, the help sheet, the issue screens, chats, the daemon view and the task workspace’s panes wrap and scroll instead of cutting text off, and the TUI uses the terminal’s alternate screen.
vincent daemon stopnow waits for the daemon to exit, containerized steps reach their MCP tools on Docker Desktop, and vincent is built with Go 1.26.10.
An end-to-end workflow
Here is how the pieces fit for a small team or a solo developer on a GitHub repository registered as project 1. The issue number and titles are an example; every command and file is the real 0.12.0 syntax.
The alternative, which I still see a lot, is a prompt in a chat window, a diff that looks plausible, and a reset when it turns out not to be.

1. The issue arrives
Someone files issue #200 on GitHub. The reconciler imports it on its next tick, or you ask for it:
vincent issue sync --project 1
id=$(vincent issue ls --project 1 --github 200 --json | jq '.[0].id')
It appears in the open lane of the issues board with no tasks, its comments mirrored under the description.
2. Talk it through
T on the card opens a chat on the issue. The first turn already carries the title, the body and the thread, and the chat cuts the issue’s main branch. The issue moves to in progress, and the card reads ● chat #C.
3. Run something beside it
While the chat holds the main worktree, a side task can run now instead of queueing, for example a fixture the reproduction needs:
vincent task add --project 1 --issue "$id" --separate-worktree --merge agent --title "Add a cold start fixture"
It runs in its own worktree cut from the main branch. When it finishes, its merge-back queues behind the chat.
4. Hand the worktree to a workflow
Close the chat with ctrl+q. The merge-back takes the worktree and merges the side branch, then the main task runs on the same directory, uncommitted chat changes included. With fix-issue.yaml from above in the repository’s .vincent/workflows/:
vincent task add --project 1 --issue "$id" --workflow fix-issue
If the merge-back still holds the worktree, the task waits queued behind it. To let a label start this step instead, an issues trigger does the same. This one is adapted from the triggers guide and passes vincent trigger validate:
id: bug-ready
enabled: true
source:
type: issues
project: 1
match:
action: labeled
labels: ready
by: [human, sync]
action:
type: create_task
workflow: fix-issue
title: '{{ .Event.Issue.Title }}'
issue: '{{ .Event.issue_id }}'
dedupe_key: 'issue:{{ .Event.issue_id }}:ready'
limits:
max_per_hour: 5
Leave on_fire at its default and the task it creates lands paused until you resume it, with its agent steps in restricted mode unless the trigger says permission: workflow.
5. The delivery is recorded
The workflow’s deliver step records a change delivery whose body ends in Closes #200. fix-issue keeps the default hand_off: auto, so once nothing of the issue is running, it moves to the hand-off lane with the marker pending:
vincent issue ls --project 1 --handoff pending
6. Hand it off
Install the example and start the handoff by hand, granting only what it needs:
vincent workflow init pr-per-issue --from pr-per-issue
vincent handoff start --project 1 --workflow pr-per-issue --grant pull.create "$id"
I on the card with pull.create ticked does the same, and arming the pr-per-issue trigger shown earlier makes it happen every time an issue reaches hand-off. The handoff’s task pushes the delivering task’s branch, opens the pull request with the delivery’s title and body, and marks the item wait.
7. Review, merge, done
The pull request is yours to review and merge. When it merges, GitHub closes #200 through Closes #200, the sync imports the close, the close records the waiting item delivered, and the issue moves to the done lane.
On a project where I trust the pipeline more, steps 6 and 7 collapse into an armed merge-train or github-handoff-train trigger that merges and closes overnight. That is a decision about the grant, not about the workflow.

Upgrading from 0.11
0.12.0 has breaking changes. This is what to check:
--github-issue: removed, along with the new-task form’s GitHub issue picker. A GitHub issue reaches a task once it is imported: look up its id withvincent issue ls --project P --github N --json, then pass--issue.- Templates:
.Issue.Numberis the vincent issue id on new tasks. Anything that hands a number toghshould read.Issue.Source.Number, or a declaredgithub_issuefield. Tasks created before the upgrade keep rendering their old snapshot unchanged. - Triggers:
type: github_issuesis deprecated in favour oftype: issues. Old triggers keep firing, andvincent trigger validateandapplywarn. Agithub_issuesevent carries the imported issue’s id, soissue: '{{ .Event.IssueID }}'links its task until you migrate. - GitHub writes: a workflow step or chat agent that ran
vincent github pr createormergeused to be accepted as you. It is now refused withforge_write_needs_human. Move that tail into arole: handoffworkflow and grant it there, asgithub-resolve-issue.yamldid. - Deleted issues: an issue deleted on GitHub is deleted in vincent on the next sync, unless a live task still holds it.
- Existing tasks: tasks linked to GitHub issues were backfilled into imported issues on upgrade. The first sync that finds each one adopts it and takes GitHub’s state, writing nothing back.
- The board: it groups by workflow by default, and the project grouping level is deprecated.
- New config keys:
tui.tabsanddefaults.background_wait. A daemon older than 0.12.0 refuses aconfig.yamlthat sets them, since unknown keys are an error. Setbackground_wait: 0if you want claude runs to end at the first answer, as before.
Next: intake from Jira
The next major step is extending the set of integrations, and the first one is Jira from Atlassian: syncing issues from Jira into vincent the same way GitHub issues are synced now. That is the point of the intake pillar being pluggable. The issues board, the lanes, --issue, .Issue, the main worktree and the handoff all work on vincent’s own issue record, so a second source should change where issues come from and nothing downstream. Jira is also the real test of that boundary. Until now it has only ever had one implementation behind it, and the pillars post already named the risk that an interface designed around one adapter encodes that adapter’s accidents.
Vincent is still 0.x, and this release spends that label on breaking changes. In return the core no longer cares whether an issue came from GitHub or was filed by hand, and the workflows do the work and stop at a delivered branch. Landing and closing happen in the handoff, where a person has said yes in advance. That is the trade I wanted when I wrote the plan. The docs, including the new issues guide and handoff guide, have the full detail, and the GitHub project takes issues and contributions. The rest of what I work on is at lezli01.is-a.dev.
// comments load as you scroll here. They live in GitHub Discussions; sign in with GitHub to join in.