Skip to content

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:

SymptomWhen it surfacesExisting patch
Step 5b frontmatter rebase conflict on (nearly) every task-work runThe 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 --- blockPR #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 limbotask-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-dispatchNo 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.

LocationRole today
plugin/skills/task-work/SKILL.mdStep 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.pyThe 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.pyOwns 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.mdEdits 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.pyReads 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.mdCaptures 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.

Three rules, applied consistently across the skills involved:

  1. Task-state frontmatter is canonical on main. Any change to status:, readiness_verified_at:, last_reviewed:, definition_gap:, completion_note:, or prs: commits on main, 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.
  2. 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.
  3. 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 says planning/needs-definition with the gap populated. task-work then tears down the worktree + branch + releases the lease before returning the TASK-WORK-NEEDS-DEFINITION verdict. No two-places-of-truth, no stuck state.
  1. 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.
  2. Refactor ensure_ready_mutate.py to take a --commit-on <branch> flag (default main). Inside task-work’s invocation, set it to main. Standalone /sdlc:task-ensure-ready invocations 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 to main (via git -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 next task-work step pulls main’s change into the worktree via git -C <worktree> checkout main -- <task-file> (or equivalent).
  3. Update task-ensure-ready/SKILL.md to 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.”
  4. Update task-work/SKILL.md Step 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/main movement, which is the legitimate rebase reason.
  5. Refactor start_task.py to 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.
  6. Add a linter assertion under plugin/scripts/ (or a project-check step) that flags any commit on a task/* 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.
LocationKindChange
plugin/skills/task-ensure-ready/ensure_ready_mutate.pymodifyAdd --commit-on <branch> flag; default to current branch for standalone runs, main for task-work invocations.
plugin/skills/task-ensure-ready/SKILL.mdmodifyDocument the commit-target convention; cross-reference task-work as the canonical caller that sets --commit-on main.
plugin/skills/task-work/SKILL.mdmodifyRewrite 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.pymodifyDrop the rebase block. Keep the start-commit-on-main behavior. Reset task branch to new main tip.
plugin/skills/task-define/SKILL.md + scriptsmodifyIf 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.pynewLinter 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.pymodifyWhatever 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).
  • AC-1: A live /sdlc:task-work <some-basename> run on a fresh open/ready task completes Step 5 with no rebase invocation and no frontmatter conflict. git log main carries 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 through main, 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 main carries a chore(tasks): flag <basename> as needs-definition commit with status: planning/needs-definition
    • definition_gap: set, AND the worktree at .sdlc/worktrees/<basename> is removed, AND the task/<basename> branch is deleted, AND the refs/sdlc/tasks/<basename> lease is released. The terminal verdict is TASK-WORK-NEEDS-DEFINITION slug=<basename>.
  • AC-3: The “other” category in count_inflight_tasks.py reports zero entries after a full /sdlc:orchestrate tick 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.py no longer invokes git rebase anywhere in its codepath. A regression test under plugin/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.py exists and is wired into /project-check. Adding a deliberate test commit on a task/* branch that only changes status: 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.)
  • 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-work chooses to spin up worktrees in the first place (worktree paths, branch naming). Those are stable.
  • none — the 2026-05-28-namespace-skill-terminal-markers pass (PR #165, merged) is independent but adjacent: this task assumes the new slug-namespaced markers across task-work, task-ensure-ready, task-define, etc. The marker pass landed; this task is the next rail of reliability work.

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:

  1. 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 widened start_task.py’s lift-readiness logic. The regression was captured in PR #159 as a draft meta-task on meta-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/).
  2. 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 is open/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.md on meta-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.

Captured by /sdlc:task-work on 2026-05-28. PR: pending.

  • AC-1: auto — plugin/skills/task-work/test_start_task.py::test_happy_path + test_post_step5_topology verify that after Step 5b’s script run, git log main carries 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.md Step 5a (worktree + branch + lease teardown after ensure-ready --commit-on main lands 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 main redirect, task-work’s cleanup procedure, terminal TASK-WORK-NEEDS-DEFINITION slug=<basename> verdict) are all in place.
  • AC-3: deferred-user — depends on a full /sdlc:orchestrate tick 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, so count_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_source asserts the script’s source no longer carries a "rebase" git argument. The companion test_post_step5_topology confirms the commit topology.
  • AC-5: auto — plugin/scripts/lint_task_state_commit_origin.py exists; plugin/scripts/test_lint_task_state_commit_origin.py::test_state_only_commit_flagged verifies 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.py wrapper deferred per the friction section below).
  • AC-6: deferred-user — depends on a future multi-task /sdlc:orchestrate run 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.
  • The mutator redirect (--commit-on <branch>) cleanly inverts the commit target without disturbing the existing --commit standalone path. Backward compatibility for direct callers (and the eval suite) was free.
  • Replacing git rebase main with git reset --hard main in start_task.py was 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-branch is 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 once origin/main moves forward.
  • .claude/ tree is write-locked mid-flight — could not create the canonical .claude/skills/project-check/check_task_state_origin.py wrapper or extend .claude/skills/project-check/SKILL.md’s prose to mention the new check. Wired the linter into lefthook.yml instead, 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_working test in plugin/skills/task-work/test_start_task.py failed with No module named 'pydantic' both before and after my changes — the test’s local shim adds plugin/lib to sys.path but the bare-python3 invocation 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 under uv run consistently; 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-DEFINITION marker 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. A plugin/conventions/sub-skill-intermediate-markers.md would catch the next variant before it ships.

← Back to Tasks