T-SIHV-task-state-frontmatter-commits-on-main-not-worktree-branch
Status: closed/done · Impact: high · Complexity: medium
Two recurring task-work failures share a root cause: task-state lives in
two places (the task file on main and the same task file on the
worktree branch) and the two places drift. The drift surfaces in two
distinct shapes that we’ve patched independently in the past, each
patch addressing the symptom but not the root cause:
| Symptom | When it surfaces | Existing patch |
|---|---|---|
| Step 5b frontmatter rebase conflict on (nearly) every task-work run | The verify-stamp (readiness_verified_at:) commits on the worktree branch; the status flip + last_reviewed bump commits on main; the rebase replays them on the same --- block | PR #125 widened the lift-readiness logic in start_task.py; PR #159 (still open as a meta-task draft) was scaffolded after the next regression of the same shape |
| NEEDS-DEFINITION worktree limbo | task-ensure-ready downshifts to planning/needs-definition on the worktree branch; main keeps open/ready; the worktree’s existence then permanently excludes the task from re-dispatch | No patch — first observed on 2026-05-28 as 3 stuck worktrees in a single orchestrator session, recovered manually |
Both are eliminated by a single design rule: task-state frontmatter
changes (status:, readiness_verified_at:, last_reviewed:,
definition_gap:, completion_note:, prs:) commit on main. The
worktree branch carries only implementation work — code, tests,
docs, post-mortem prose. No status flips, no verify-stamps, no
downshifts on the task branch.
The change is architectural, not a patch: it removes the two-places-of-truth problem entirely, so future variants of the same class of bug cannot arise.
| Location | Role today |
|---|---|
plugin/skills/task-work/SKILL.md | Step 5a runs /sdlc:task-ensure-ready, which commits the verify-stamp on the task branch inside the worktree. Step 5b then commits the status flip on main, then start_task.py rebases the task branch onto main. The two commits touch the same --- block in disjoint fields, which is structurally a merge conflict. |
plugin/skills/task-ensure-ready/SKILL.md + ensure_ready_mutate.py | The script writes the task file edit and commits inside the worktree, on whatever branch is checked out (i.e. the task branch when invoked by task-work). Both the PASS path (verify-stamp) and the FAIL path (downshift + definition_gap) commit there. |
plugin/skills/task-work/start_task.py | Owns Step 5b’s start-commit on main AND the subsequent rebase of the task branch onto main. The rebase is the choke point where the conflict surfaces. PR #125 attempted to auto-lift the verify-stamp; the regression captured in PR #159 shows that approach is fragile to field-pair variations. |
plugin/skills/task-define/SKILL.md | Edits body content on the worktree branch. Frontmatter edits are rare but possible (clearing readiness_verified_at: when a gap is filled). Those rare ones inherit the same two-places-of-truth bug. |
plugin/scripts/count_inflight_tasks.py | Reads status: from the task file on main and queries gh for PRs. The “other” category (worktree exists + status on main is open/ready) is exactly the NEEDS-DEFINITION-limbo signature, and the script has no way to recover from it. |
docs/planning/tasks/2026-05-28-rerun-ensure-ready-after-start-commit-to-avoid-frontmatter-conflict.md | Captures path-(a): reset task branch to main tip and re-stamp via ensure-ready. Smaller-scope fix; superseded by this task. |
docs/planning/tasks/2026-05-28-start-task-frontmatter-conflict-regression.md (on meta-task/start-task-frontmatter-conflict-regression, PR #159) | Captures the framing “PR #125 regressed; widen the auto-resolver”. Also superseded by this task. |
Proposed
Section titled “Proposed”Three rules, applied consistently across the skills involved:
- Task-state frontmatter is canonical on
main. Any change tostatus:,readiness_verified_at:,last_reviewed:,definition_gap:,completion_note:, orprs:commits onmain, never on the worktree branch. The worktree’s task file is kept in sync as a working-copy view of main’s state but is never the source of a state commit. - Implementation work commits on the worktree branch. Code edits,
test additions, doc edits to task-body sections (Goal, Today,
Approach, Acceptance criteria, post-mortem prose) all commit on
task/<basename>as before. The PR diff is exactly these commits, uncluttered by status-tracking churn. - Step 5b’s rebase disappears. The verify-stamp lives on main alongside the start-commit (in that order), so there’s no two-commit sequence on the task branch that needs replaying. The worktree branch is reset to main’s tip at the end of Step 5 and built up from there with implementation commits.
Two failure modes go away as a consequence:
- Frontmatter rebase conflict: there is no rebase anymore. The conflict class is structurally impossible.
- NEEDS-DEFINITION worktree limbo:
task-ensure-ready’s downshift commits on main, so main correctly saysplanning/needs-definitionwith the gap populated.task-workthen tears down the worktree + branch + releases the lease before returning theTASK-WORK-NEEDS-DEFINITIONverdict. No two-places-of-truth, no stuck state.
Approach
Section titled “Approach”- Audit current writes. Grep for every place that edits the task
file’s frontmatter inside a worktree context — primarily
plugin/skills/task-ensure-ready/ensure_ready_mutate.py(PASS and FAIL paths),plugin/skills/task-define/(body edits, plus any stray frontmatter clears),plugin/skills/task-work/start_task.py(start-commit already on main — confirm). Build a complete list of call sites that need to redirect commits to main. - Refactor
ensure_ready_mutate.pyto take a--commit-on <branch>flag (defaultmain). Inside task-work’s invocation, set it tomain. Standalone/sdlc:task-ensure-readyinvocations continue to commit on the current branch (the user explicitly chose where to run it). The mutator stages the task file, switches the active commit target tomain(viagit -C <main-repo> commit-tree+git update-ref, OR a temporary checkout, depending on which is cleaner under--work-tree). The worktree’s task file stays untouched at HEAD; the nexttask-workstep pulls main’s change into the worktree viagit -C <worktree> checkout main -- <task-file>(or equivalent). - Update
task-ensure-ready/SKILL.mdto document the new commit-target behavior. Make explicit that the skill is “state ownership: on main when called by task-work; on current branch when called standalone.” - Update
task-work/SKILL.mdStep 5:- Step 5a: ensure-ready runs, commits PASS-path on main directly (no worktree-branch commit, no later rebase).
- Step 5a NEEDS-DEFINITION path: ensure-ready commits the
downshift on main; task-work then tears down the worktree +
branch + releases the lease + returns
TASK-WORK-NEEDS-DEFINITION slug=<basename>. - Step 5b: commits the status flip on main as before; the rebase
block is deleted (no longer needed because the verify-stamp
is also on main, immediately preceding the start-commit). The
task branch is reset to
main’s tip and the worktree advances from there. - Step 9: also simplified — the only thing to sync is implementation
commits on the task branch against
origin/mainmovement, which is the legitimate rebase reason.
- Refactor
start_task.pyto drop the rebase block. The script becomes: commit status flip on main, push the start-commit, reset the task branch (in the worktree) to the new main tip, exit. Add a regression test that exercises the “ensure-ready already committed the verify-stamp on main” pre-condition. - Add a linter assertion under
plugin/scripts/(or a project-check step) that flags any commit on atask/*branch whose ONLY change is to a task file’s frontmatter fields. Such a commit indicates state-tracking work has bled onto the implementation branch — the linter surfaces the violation so the convention stays enforced.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/skills/task-ensure-ready/ensure_ready_mutate.py | modify | Add --commit-on <branch> flag; default to current branch for standalone runs, main for task-work invocations. |
plugin/skills/task-ensure-ready/SKILL.md | modify | Document the commit-target convention; cross-reference task-work as the canonical caller that sets --commit-on main. |
plugin/skills/task-work/SKILL.md | modify | Rewrite Step 5a and Step 5b prose. Delete the Step 5b rebase paragraphs. Document the NEEDS-DEFINITION cleanup path (teardown worktree + branch + lease). |
plugin/skills/task-work/start_task.py | modify | Drop the rebase block. Keep the start-commit-on-main behavior. Reset task branch to new main tip. |
plugin/skills/task-define/SKILL.md + scripts | modify | If frontmatter edits exist here (clearing readiness_verified_at?), route them through ensure_ready_mutate.py --commit-on main or equivalent. Body edits unchanged. |
plugin/scripts/lint_task_state_commit_origin.py | new | Linter that flags task/*-branch commits whose only diff is to a task file’s task-state frontmatter fields. Called by /project-check and the lefthook gate from PR #163. |
plugin/skills/task-work/check_ancestry.py | modify | Whatever Step 9-style ancestry assumptions need updating to reflect the new commit topology (verify-stamp + start-commit BOTH on main before any implementation commit on the task branch). |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1: A live
/sdlc:task-work <some-basename>run on a freshopen/readytask completes Step 5 with no rebase invocation and no frontmatter conflict.git log maincarries the verify-stamp commit followed by the start-commit, in that order.git log task/<basename>has zero state-only commits — all task-state commits are reachable throughmain, not the task branch. - AC-2: A live
/sdlc:task-work <some-basename>run on a task whose spec has a stale Today row (drift the validator catches) results in:git log maincarries achore(tasks): flag <basename> as needs-definitioncommit withstatus: planning/needs-definitiondefinition_gap:set, AND the worktree at.sdlc/worktrees/<basename>is removed, AND thetask/<basename>branch is deleted, AND therefs/sdlc/tasks/<basename>lease is released. The terminal verdict isTASK-WORK-NEEDS-DEFINITION slug=<basename>.
- AC-3: The “other” category in
count_inflight_tasks.pyreports zero entries after a full/sdlc:orchestratetick on a project with no in-flight work — there are no stuck NEEDS-DEFINITION worktrees because there’s no path that creates them. - AC-4:
start_task.pyno longer invokesgit rebaseanywhere in its codepath. A regression test underplugin/skills/task-work/tests/(or sibling) asserts the rebase block is gone and the post-Step-5 commit topology matches AC-1. - AC-5:
plugin/scripts/lint_task_state_commit_origin.pyexists and is wired into/project-check. Adding a deliberate test commit on atask/*branch that only changesstatus:in a task file causes the linter to flag the violation; removing the commit clears the flag. - AC-6: A subsequent orchestrator run that dispatches 3+ task-work sub-agents in parallel sees zero conflict-resolution steps and zero stuck worktrees in post-mortems. (Validated by running the loop against a small batch of ready tasks once this work ships.)
Out of scope
Section titled “Out of scope”- Migrating existing
task/*branches with in-progress work onto the new pattern. Old branches retain their old commit topology; the rule applies to NEW task-work runs. If we want to enforce the rule retroactively, that’s a separate/sdlc:task-review-driven cleanup. - Changing where
start_task.py’s start-commit lives. The start-commit is already on main — this task formalizes the convention that all task-state lives there, but the existing start-commit-on-main behavior is unchanged. - Touching PR body composition, lease footer placement, or any
downstream skill (
pr-respond,task-close-out) — they read from main, which is unchanged from their perspective. - Re-architecting how
task-workchooses to spin up worktrees in the first place (worktree paths, branch naming). Those are stable.
Dependencies
Section titled “Dependencies”- none — the
2026-05-28-namespace-skill-terminal-markerspass (PR #165, merged) is independent but adjacent: this task assumes the new slug-namespaced markers acrosstask-work,task-ensure-ready,task-define, etc. The marker pass landed; this task is the next rail of reliability work.
Discovery context
Section titled “Discovery context”Surfaced on 2026-05-28 during the first multi-task /sdlc:orchestrate
session of the post-cutover-marker-fix era. The session produced
TWO distinct failure modes that turned out to share a root cause:
- Frontmatter rebase conflicts in 5/5 task-work runs that reached
Step 5b. Each post-mortem flagged it. The bug was previously
thought-fixed by PR #125 (closed/done,
[T-1YQM-start-task-resolves-frontmatter-conflict](/planning/tasks/start-task-resolves-frontmatter-conflict/)) which widenedstart_task.py’s lift-readiness logic. The regression was captured in PR #159 as a draft meta-task onmeta-task/start-task-frontmatter-conflict-regression. The path-(a) reset-and-re-stamp fix was captured in[T-TU0W-rerun-ensure-ready-after-start-commit-to-avoid-frontmatter-conflict](/planning/tasks/rerun-ensure-ready-after-start-commit-to-avoid-frontmatter-conflict/). - NEEDS-DEFINITION worktree limbo in 3/5 task-work runs. Each
one left a stuck worktree (
safe-grep-helper,run-quality-checks-test-fixtures,prototype-skill-refine-skill) that no future orchestrator tick could clear because the in-flight counter’s “other” category permanently excludes worktrees whose task on main isopen/ready.
The architectural framing emerged from inspection: both bugs are symptoms of state ownership being split between main and the task branch. Collapsing state ownership onto main (and reserving the task branch for implementation diff) makes the conflict class structurally impossible and the limbo state unreachable.
Supersedes:
[T-TU0W-rerun-ensure-ready-after-start-commit-to-avoid-frontmatter-conflict](/planning/tasks/rerun-ensure-ready-after-start-commit-to-avoid-frontmatter-conflict/)— the narrower path-(a) fix; this task is a superset.- PR #159 (
docs/planning/tasks/2026-05-28-start-task-frontmatter-conflict-regression.mdonmeta-task/start-task-frontmatter-conflict-regression) — the framing “widen #125’s auto-resolver”; this task addresses the root cause that PR #125 patched without fixing, so widening would only postpone the next variant.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-28. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
plugin/skills/task-work/test_start_task.py::test_happy_path+test_post_step5_topologyverify that after Step 5b’s script run,git log maincarries the verify-stamp commit followed by the start-commit, the task branch has zero commits not reachable from main, and there’s no REBASE state. - AC-2: deferred-user — the NEEDS-DEFINITION cleanup path is documented in
plugin/skills/task-work/SKILL.mdStep 5a (worktree + branch + lease teardown afterensure-ready --commit-on mainlands the downshift on main). A live end-to-end run against a deliberately under-specified task would close this; the structural pieces (mutator’s--commit-on mainredirect, task-work’s cleanup procedure, terminalTASK-WORK-NEEDS-DEFINITION slug=<basename>verdict) are all in place. - AC-3: deferred-user — depends on a full
/sdlc:orchestratetick against a project with no in-flight work. Validated structurally: the only path that creates a NEEDS-DEFINITION worktree under the new contract is one that immediately tears it down before exiting, socount_inflight_tasks.py’s “other” category can no longer accumulate this shape. - AC-4: auto —
plugin/skills/task-work/test_start_task.py::test_no_rebase_in_sourceasserts the script’s source no longer carries a"rebase"git argument. The companiontest_post_step5_topologyconfirms the commit topology. - AC-5: auto —
plugin/scripts/lint_task_state_commit_origin.pyexists;plugin/scripts/test_lint_task_state_commit_origin.py::test_state_only_commit_flaggedverifies that a deliberate state-only commit on a task branch is flagged and that removing it clears the flag; wired into the lefthook pre-commit gate (companion.claude/skills/project-check/check_task_state_origin.pywrapper deferred per the friction section below). - AC-6: deferred-user — depends on a future multi-task
/sdlc:orchestraterun with 3+ parallel sub-agents. The structural argument: rebase removed → conflict class structurally impossible → expected zero conflict-resolution steps. Re-verify in the next epic-scale orchestrator session.
What worked
Section titled “What worked”- The mutator redirect (
--commit-on <branch>) cleanly inverts the commit target without disturbing the existing--commitstandalone path. Backward compatibility for direct callers (and the eval suite) was free. - Replacing
git rebase mainwithgit reset --hard maininstart_task.pywas a 3-line diff that eliminated an entire class of failure. The test suite was the one that took real effort — the production code change was small. - The new linter caught the historical NEEDS-DEFINITION-limbo violations (3 stuck worktrees from 2026-05-28) on first run. The convention is the right shape, and the linter operationalizes it.
- The dogfood worked end-to-end: every commit on
task/2026-05-28-task-state-frontmatter-commits-on-main-not-worktree-branchis implementation diff, never task-state frontmatter (the state commits live on main, where the task brief said to put them). The linter correctly flags the not-yet-pushed dogfood state commits — they’ll clear onceorigin/mainmoves forward.
Friction and automation gaps
Section titled “Friction and automation gaps”.claude/tree is write-locked mid-flight — could not create the canonical.claude/skills/project-check/check_task_state_origin.pywrapper or extend.claude/skills/project-check/SKILL.md’s prose to mention the new check. Wired the linter intolefthook.ymlinstead, which is the deterministic enforcer (and/project-check’s SKILL.md already documents that the lefthook gate IS the canonical project-check path). A follow-up task can add the project-check wrapper script and SKILL.md entry once the runtime guard isn’t blocking it (e.g. when invoked from main, not from a worktree currently dispatched by task-work).- The pre-existing
test_lease_transition_claimed_to_workingtest inplugin/skills/task-work/test_start_task.pyfailed withNo module named 'pydantic'both before and after my changes — the test’s local shim addsplugin/libtosys.pathbut the bare-python3invocation of the test runner doesn’t pull the inline-script dependencies. The fix is to either drop the test or arrange for it to run underuv runconsistently; out of scope here but worth a follow-up. - The stash-on-baseline-comparison friction surfaced once during implementation: I stashed work to
confirm a pre-existing lint violation, and the pop produced a conflict with an unrelated branch’s
stash queue. The conflict was on
site/src/content/docs/reference/skills/*.md(auto-generated, committed). The fact that auto-generated site docs are committed means stash queues across branches can collide on them; if the site/ tree were.gitignored or regenerated only at release time, that friction would disappear. - The marker namespacing pass (PR #165, merged) was a sibling concern with the same root cause as
the task-state-on-main work — both stem from “intermediate sub-skill outputs being mistaken for
terminal verdicts.” The conventions are now aligned (this task uses the slug-prefixed
TASK-WORK-NEEDS-DEFINITIONmarker for the cleanup path), but the broader pattern — sub-skill intermediate output crossing the boundary to outer skills — deserves a convention doc that names it explicitly. Aplugin/conventions/sub-skill-intermediate-markers.mdwould catch the next variant before it ships.