Skip to content

/sdlc:task-work

Generated from solutions/ontological/skills/task-work/SKILL.md.

Pick up a task from docs/planning/tasks/, validate it, acquire its lifecycle lease, spin up a worktree, run the implementation-ready gate, acquire the run’s phase on the lease once that gate passes, clarify open questions, implement via sub-agent (with a heartbeat thread keeping the lease alive), run local quality checks, write the handoff.md blob, transition the lease to awaiting-review, and open a PR carrying the lease-binding footer. Exits at PR open; /sdlc:pr-respond and /sdlc:task-close-out take over from there.

  • Read
  • Write
  • Edit
  • Bash
  • Glob
  • Grep
  • Agent
  • AskUserQuestion
  • Skill
  • WebFetch
  • WebSearch

Usage:

  • /sdlc:task-work <slug-or-filename> — work the named task. Match against filename, e.g. time-zone, time-zone-boundary-helpers, or the full 2026-05-12-time-zone-boundary-helpers. The explicit form is the author’s opt-in: it works regardless of the task’s autonomy: field.

  • /sdlc:task-work — pick the next task via

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc task next --status open/ready --exclude-autonomy human-only --limit 1

    (canonical pickup-order; see docs/planning/decisions/D-Q2WR-task-pickup-order.md). If the verb emits no output, echo NO READY TASKS FOUND and exit.

Project context (don’t re-derive every run):

  • Task documents live in docs/planning/tasks/. Frontmatter fields used here: status, autonomy, last_reviewed, relevance_note, completion_note. Full schema in docs/planning/tasks/README.md.

  • Frontmatter is validated by the sdlc plugin’s schema. After any edit that touches frontmatter (Step 5 ensure-ready / start-commit), run:

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc entities validate <path-to-task-file>

    The script is a Bun-run CLI; it resolves the right schema (entities/task/schema.ts) automatically. If it reports a failure, fix the frontmatter before committing — the validator is part of the contract, not advisory. Schema lives at ${CLAUDE_PLUGIN_ROOT}/entities/task/schema.ts.

  • Status uses a four-major stage or stage/reason form:

    • Planning (spec still being shaped, not pickable): planning/draft, planning/proposed, planning/needs-definition, planning/backlog
    • Open (available to pick up): open/ready
    • In-progress (someone has it): carried by the LEASE’s phase, not by status:. The in-progress / in-progress/blocked enum values are legacy — tolerated on read through the deprecation window, never written ([[D-S30G-task-state-plane-split]])
    • Closed (always with reason): closed/done, closed/superseded, closed/partially-superseded, closed/obsoleted, closed/relocated, closed/no-repro, closed/wontdo
    • Closed tasks must include a completion_note: body field.
  • Worktrees: .sdlc/worktrees/<task-basename> (basename = filename without .md).

  • Branch: task/<task-basename> (see ${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.md).

  • Quality checks: declared per-project in <project-root>/sdlc.yaml under the quality_checks: key (a list of shell verbs). See ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md for the shape. Step 7 invokes ${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run against that file rather than hard-coding any specific runner. Absent file or empty list emits a visible warning and skips the gate (no silent fallback).

  • Worktree initialisation: also declared per-project in sdlc.yaml, under the worktree_init: key (same shape — a list of shell verbs). Step 4 invokes the same executor with --key worktree_init --allow-empty so that projects without commit-msg hooks (Rust, plain Python) silently no-op rather than prompting the operator to invent a recipe.

References:

  • ${CLAUDE_PLUGIN_ROOT}cli/sdlc task resolve <arg> — the deterministic resolver for a <slug-or-filename> argument (see ${CLAUDE_PLUGIN_ROOT}/entities/task/file-resolution.md for the resolution spec it implements, or sdlc task resolve --help). Step 1 calls this op rather than walking the prose procedure.
  • ${CLAUDE_PLUGIN_ROOT}/entities/task/implementation-ready.md — readiness contract (consulted via /sdlc:task-ensure-ready in Step 5).
  • ${CLAUDE_PLUGIN_ROOT}/entities/task/spawn-from-post-mortem.md — procedure for converting post-mortem gap bullets into follow-up task files (consumed by the sub-agent in Step 8).

If an argument was given, resolve it by running ${CLAUDE_PLUGIN_ROOT}cli/sdlc task resolve <arg> --output json per ${CLAUDE_PLUGIN_ROOT}/entities/task/file-resolution.md (the op reproduces that spec; the doc owns the resolution order, the marker grammar, and the exit-code contract). This skill’s ambiguous-policy mode is interactive, so it runs the op in --output json mode (not TEXT mode) to get the structured candidates[] its prompt needs, and branches on the parsed JSON (resolved, then reason), NOT on the TEXT-mode stderr markers, which --output json does not emit.

On resolved: true, capture the JSON path (absolute) and basename and continue — this is the absolute-path-plus-basename the steps below expect. This skill’s divergent per-outcome ACTION:

  • ambiguous (resolved: false, reason: "ambiguous") — prompt the user via AskUserQuestion with the JSON candidates[] and use the picked one. If the user bails, surface NO TASK FOUND for "<arg>" and exit.
  • not-found (resolved: false, reason: "not-found") — surface NO TASK FOUND for "<arg>" and exit.

Explicit-by-name pickup is the author’s opt-in; do NOT filter on autonomy: here. If the resolved task is autonomy: human-only, surface that to the user via AskUserQuestion: “This task is labeled autonomy: human-only. Proceed anyway, or stop?” Default: stop.

If no argument: shell out to the canonical pickup-order verb (${CLAUDE_PLUGIN_ROOT}cli/sdlc task next --status open/ready --exclude-autonomy human-only --limit 1) — the verb owns the sort-key chain, the priority:-aware lift, the dependency propagation, and the default dispatchability filter (a task blocked on an unsatisfied depends_on is never picked) (see docs/planning/decisions/D-Q2WR-task-pickup-order.md). Take the single basename emitted on stdout as the task to work. If the verb emits nothing, exit NO READY TASKS FOUND.

Read the file. Capture: status, autonomy, last_reviewed, relevance_note, headline (the first # line).

After the task file is read and the headline captured, check for an executable hook at .sdlc/skill-ext/task-work/step1-post.sh in the project root (not the worktree — the hook lives in the main checkout and is committed to the consuming repo). If present and executable, invoke it with:

  • Environment: TASK_FILE=<absolute path to the resolved task file>
  • Working directory: the project root.
  • Argv: none.

The hook is informational — exit code is ignored, stdout/stderr are surfaced verbatim to the operator. The hook MUST NOT modify the task file, MUST NOT modify any plugin file, and SHOULD complete in under a second (it runs synchronously on every task pickup).

Absent or non-executable hook: skip silently (extension points are opt-in per project).

Hook contract and rationale documented at ${CLAUDE_PLUGIN_ROOT}conventions/project-local-skill-extension.md.

Probe the single-task pre-flight signals with one read-only shell-out (see sdlc task probe-state --help):

${CLAUDE_PLUGIN_ROOT}cli/sdlc task probe-state <basename> --output json

The stop/proceed decision — and the “don’t sweep the user’s WIP” consent hand-off in the resume branch below — stay with this skill’s AskUserQuestion. The JSON carries the raw signals (worktree_exists, branch_exists, task_status, open_pr_number, readiness_verified_at, main_head_subject, main_head_is_verify_stamp) plus two derived gates the skill branches on: blocked_preflight and resume_candidate. (Pass --no-gh to skip the gh pr list round-trip in a hermetic/offline run; open_pr_number then reads null and the open-PR blocker is not evaluated.)

Block and report (do not proceed) when blocked_preflight is true — it is true if ANY of these hold:

  • Status starts with closed/ (any reason — closed/done, closed/obsoleted, etc.).
  • Status is the legacy in-progress or in-progress/blocked (a file no run has healed yet).
  • An active lease already holds the task and it is not the resume shape below.
  • A worktree already exists at .sdlc/worktrees/<basename> (worktree_exists).
  • A branch already exists named task/<basename> (branch_exists).
  • A PR is already open referencing this task (open_pr_number non-null).

The planning/* soft-gate below is deliberately NOT folded into blocked_preflight — it is a judgment hand-off to AskUserQuestion, not a hard block.

If status is in the planning/* family (planning/draft, planning/proposed, planning/needs-definition, planning/backlog), surface this to the user via AskUserQuestion: “This task is <status>, not open/ready. Proceed anyway, or stop?” Default: stop.

Resume detection — recover from a stalled previous run

Section titled “Resume detection — recover from a stalled previous run”

Before treating the worktree/branch/PR blockers above as terminal, check whether the previous /sdlc:task-work run stalled after acquiring the lease. The stall shape is: the lease was claimed and the worktree built, but the run never reached PR-open (crashed sub-agent, sandbox denial mid-flow, user interrupt, or the marker-confusion failure closed by [[T-ICA5-task-work-sub-agent-verdict-contract-clarity]]). This branch turns that half-finished state into a single-command resume rather than letting the “worktree/branch already exists” blockers above wedge it permanently.

The lease is the signal. Under the [[D-S30G-task-state-plane-split]] plane rule, execution state lives on the lease and frontmatter status: is a derived cache — so “is a run half-finished?” is a question only the lease can answer. The probe from the top of Step 2 reads it and decides: resume_candidate is true iff ALL of the following hold simultaneously. Treat the run as a resume rather than a fresh pickup exactly when resume_candidate is true:

  • Task status: on main is open/ready — which it is for the task’s whole in-flight life; the split retired the start commit that used to flip it to in-progress.
  • The probe’s lease_phase is claimed or working — a lease exists, and it has not yet reached review. This is the resume signal.
  • A worktree exists at .sdlc/worktrees/<basename>.
  • A branch task/<basename> exists.
  • No PR is open referencing this task (open_pr_number is null).

The probe reads the LOCAL ref mirror, so this costs no network. A stale mirror degrades safely: a missing ref leaves lease_phase null and the run falls through to the blockers rather than resuming something it cannot see.

(The retired signal was main_head_is_verify_stamp — main’s HEAD subject matching the literal verify-stamp commit. It was fragile by construction: any later commit on main, from any session, silently broke resume detection for every in-flight task. The stamp commit is gone with the plane split, and the field survives in the probe’s output as legacy diagnostic only. readiness_verified_at likewise: it now answers “was this ready when it was PROMOTED”, which is not a per-run fact and so cannot gate a per-run resume.)

Any other combination — no lease, a lease already at awaiting-review or beyond, a PR already open — leaves resume_candidate: false and falls through to the existing pre-flight blockers above (they remain in force).

When the conditions hold, surface the resume option to the user via AskUserQuestion: “Detected a prior /sdlc:task-work run that stalled mid-flight (lease held at <phase>, worktree and branch present, no PR open). Resume at Step 5b, or start over?” Offer two options:

  1. Resume at Step 5b — keep the existing worktree and task branch; skip Step 4 (worktree create) and Step 5a (ensure-ready); jump directly to Step 5b (lease transition + reset the task branch onto the current origin/main tip). The lease already carries this run’s readiness gate in its gates object, so re-running the gate would be a no-op anyway.
  2. Start over — stop here. Do NOT auto-clean — removing a worktree without explicit consent is the kind of destructive default to avoid. The user manually removes the worktree (git worktree remove --force .sdlc/worktrees/<basename>) and deletes the branch (git branch -D task/<basename>) before re-invoking /sdlc:task-work.

Default: stop. The user picks resume explicitly. If the user chooses resume, Steps 3a (baseline capture) and 3b (permissions probe) still run — both are read-only and cheap — then control jumps to Step 5b. Steps 4 and 5a are skipped. The baseline captured here is the one Step 7 will gate against on the resumed run; if a stale baseline already exists for the same SHA, capture overwrites it (the cache is keyed on SHA, not on session).

The task was written at some past last_reviewed date and the codebase has moved. Before committing to the work, sanity-check that the task still describes reality.

Do all of these:

  1. Read the task end-to-end.
  2. Extract every file path referenced in the task (regex roughly [\w/\-\.]+\.(rs|ts|tsx|vue|js|md|toml|json|sql)(:\d+)?).
  3. For each path, confirm it exists with ls/Read. Note any missing or moved files.
  4. For any symbol/function names the task mentions as “currently does X”, command grep the codebase to confirm they still exist with that shape. (Use command grep rather than bare grep — some shells alias grep to rg rewrites that fail on BSD flags.)
  5. If relevance_note references something specific (“X is now duplicated in Y”), spot-check the claim still holds.

If the task is materially out of date (files moved, the bug was already fixed, the duplication was already collapsed, etc.), do NOT proceed into implementation. Report findings to the user and ask via AskUserQuestion how to proceed:

  • Update the task doc to reflect current reality, then proceed.
  • Mark task closed/superseded / closed/done (or other appropriate closed/<reason>) with a completion_note.
  • Stop and let the user reconsider.

If the task is still accurate, summarize the relevance check briefly (one sentence — “All referenced paths still exist; parse_local duplication still present at the two cited sites.”) and continue.

Before any side effect — worktree creation, branch push, PR open — hold the task’s lifecycle lease at refs/sdlc/tasks/<basename>. The lease ref’s presence authorizes the destructive steps that follow; a mismatched owner means another host holds the claim and we stop.

This is the canonical acquire-or-inherit point. Two execution paths funnel through one library call:

  1. Operator-direct invocation (/sdlc:task-work <slug> against a clean authority): no lease ref exists yet. The call cas_creates a fresh claimed lease whose owner is this host’s host_id.
  2. Orchestrate-dispatched invocation: /sdlc:orchestrate already cas_created the lease in its dispatch step (per [[T-X24I-orchestrate-lease-aware-dispatch]]). The same call sees the pre-existing ref, fetch_refs its payload, and if owner == current_host_id() returns the inherited payload (same-host inheritance); otherwise it raises LeaseConflict.

Shell out to the lease CLI. The sdlc lease task acquire subcommand wraps the library’s acquire_lease helper and surfaces its semantics through exit codes the skill branches on:

${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task acquire <basename>

(The skill operates inside the worktree, so the working directory is already correct. --project-root defaults to cwd; the CLI reads lease_authority: from <project-root>/sdlc.yaml automatically.)

Branch on exit code:

  • Exit 0 — the lease is held. The CLI emits one stdout line in the shape ACQUIRED task=<basename> lease_id=<uuid> phase=claimed. Parse the lease_id= token off that line and keep it in scope — Step 10’s PR body footer requires it. Downstream shell-outs that need the lease’s current state issue their own sdlc lease inspect or library call; each lookup is one git fetch round-trip against the authority, no in-skill state threading required.
  • Exit 4LEASE-CONFLICT ref=<ref> reason=owner=<other-host-id> on stderr. Another host already holds the lease. Exit task-work immediately with the same marker on stderr. No worktree creation, no branch push, no PR. The conflicting owner UUID gives the operator something to grep against the dispatcher’s logs.
  • Exit 1 — the lease library is unavailable, the authority isn’t configured, or some other generic LeaseError fired. The CLI’s stderr names the cause (error: ...). Surface to the user and stop; no side effect attempted.

On success the lease’s phase is claimed; Step 5b’s start_task.ts invocation will CAS-REPLACE it to working. Every subsequent transition (Step 6’s heartbeat, Step 10’s transition to awaiting-review) lands on the same ref by re-invoking the CLI or its sibling scripts — never by importing the library inline.

No fallback path. If the CLI exits non-zero for any reason, exit task-work with the same diagnostic surface (the CLI’s stderr line). Do not improvise a lease-disabled run: there is no --with-lease opt-in and no env-var bypass.

3. Pre-flight on main — do NOT commit yet

Section titled “3. Pre-flight on main — do NOT commit yet”

Stay on main. Do NOT modify the task file here. Do NOT commit anything yet — the chore(tasks): start <basename> commit is deferred to Step 5’s success path, after /sdlc:task-ensure-ready confirms the spec is implementation-ready.

Confirm via git status that the working tree on main is clean enough to branch from. If the user has unrelated uncommitted changes on main, note them but proceed — Step 4 branches a worktree from main without touching the index.

If a relevance-check finding from Step 2 needs to be recorded in the task body (e.g. a note that a referenced file has moved), defer that edit to Step 5 as well; bundle it with the start-commit on main so the task file only changes once on main per run.

sdlc quality run (Step 7) gates the PR on the project’s declared quality_checks: verbs. Some verbs (notably sdlc entities audit) emit pre-existing drift already broken on origin/main. Capturing a baseline now lets Step 7 gate only on drift this branch introduced, not pre-existing findings (see [[T-H69K-run-quality-checks-isolates-pre-existing-drift]]).

Capture a baseline NOW, against the current origin/main SHA, while we’re still on main and before any work has landed. The baseline is stored locally at <project-root>/.sdlc/quality-baselines/<sha>.json (the directory is gitignored). Step 7 will diff HEAD’s findings against this baseline and only gate on what the branch newly introduced.

Procedure:

  1. Resolve the current origin/main SHA:

    git fetch -q origin main
    ORIGIN_MAIN_SHA=$(git rev-parse origin/main)

    Record ORIGIN_MAIN_SHA — you will pass it to Step 7. Keep it as a local shell variable for the duration of the run; do not re-resolve at Step 7 (the SHA must match the baseline that captured pre-existing drift, not whatever origin/main happens to be later).

  2. Invoke the capture helper from the project root (the script runs each verb with the current working directory as cwd, so call it from <project-root>):

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc quality baseline capture "$ORIGIN_MAIN_SHA" \
    --config <project-root>/sdlc.yaml \
    --baseline-dir <project-root>/.sdlc/quality-baselines

    --baseline-dir is optional in spirit — sdlc quality run’s default at gate-time is <project-root>/.sdlc/quality-baselines/, which matches what we pass here. Pass it explicitly for both sides so a non-default override only has to be set in one place if a project ever wants to relocate the cache.

  3. Surface the count to the operator. After capture, read the written JSON and report:

    Baseline captured at $ORIGIN_MAIN_SHA: N pre-existing findings

    where N is the total number of finding lines across all verbs in verbs.<verb>.findings. This is informational — capture never fails the run; even if the baseline contains many findings, that IS the baseline, and Step 7’s gate will subtract them out.

  4. If <project-root>/sdlc.yaml is absent OR declares no quality_checks: verbs, the capture is a no-op: emit Baseline skipped: no quality_checks configured and proceed without setting ORIGIN_MAIN_SHA. Step 7 will surface the same missing-config warning it surfaces today; nothing else changes.

The baseline is cheap (one extra full run of the gate, sequenced before any code is written). The directory is auto-pruned to the 5 most recent SHAs on every capture, so the cache stays bounded.

Before creating the worktree, confirm the operator’s resolved Claude Code sandbox grants the Bash(<verb>:*) permissions the task needs. Package managers use a two-tier model:

  • Deterministic, project-grounded → hard gaps. The probe resolves the FULL SET of package managers the project uses from its lockfiles / ecosystem markers (node bun.lock/pnpm-lock.yaml/ yarn.lock/package-lock.json, Rust Cargo.toml, Python uv.lock/poetry.lock/ requirements.txt, Go go.mod) UNION the leading verb token of every quality_checks: / worktree_init: entry in the project’s sdlc.yaml. A polyglot bun+cargo repo resolves BOTH. Each resolved manager lacking a Bash(<pm>:*) grant is a hard gap.
  • Body-text heuristics → advisory warnings. A package-manager family the body mentions but the project does not resolve (e.g. a passing npm install mention in a pnpm repo) is at most a warning: line on stderr — never a hard gap, never exit 1.

Non-package-manager families (node, npx, pytest) keep their body-text-driven hard-gap behavior. The same probe ALSO checks the two file-mutation tools the implementer always needs — Write and Edit — against the worktree path Step 4 will create (<repo-root>/.sdlc/worktrees/<basename>/). Catching gaps here heads off a sandbox denial mid-implementation in Step 6 and its BLOCK/unblock churn.

Shell out to the helper:

bun run ${CLAUDE_PLUGIN_ROOT}skills/task-work/preflight_permissions.ts <absolute path to task file>

Exit codes:

  • 0 — no hard gaps: every resolved package manager is covered, no non-PM body signal fired uncovered, AND Write/Edit are both granted for the would-be worktree path. Advisory warning: lines may still print to stderr (they never gate). Continue silently to Step 4.
  • 1 — one or more hard gaps. Stdout lists them, one per line. Bash gaps render as <tool-family>: missing Bash(<verb>:*); file-mutation gaps render as <tool>: missing <tool>(<worktree-glob>) (e.g. Write: missing Write(/repo/.sdlc/worktrees/<basename>/**)). Surface the gaps to the user via AskUserQuestion and offer three options:
    1. Grant via /config — let the user add the missing permission(s), then re-run the probe.
    2. Proceed anyway — accept that the implementing sub-agent may hit a sandbox denial in Step 6 and handle it via the existing <blocked> flow.
    3. Abort — stop /sdlc:task-work; no worktree is created.
  • 2 — bad arguments (task file unreadable). Surface to the user and stop.

For the file-mutation check the probe honours the bare-tool form (Write / Edit with no parens = all paths), the path-scoped form (Edit(.sdlc/worktrees/**) / Edit(//abs/path/**) / Edit(~/rel/**)), and deny-beats-allow exactly as the Bash check does; defaultMode: acceptEdits or bypassPermissions (read from the same settings files) counts as a blanket allow, so no file-mutation gap is reported under either mode.

The probe is best-effort. A passing exit does not guarantee permission coverage of every shell-out the sub-agent will need — only that every resolved package manager and any fired non-PM tool family is covered, and that Write/Edit cover the worktree path.

git worktree add .sdlc/worktrees/<basename> -b task/<basename> main

The task/<basename> prefix is the canonical convention (see ${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.md).

From here on, all subsequent work happens in that worktree path. Use absolute paths in tool calls; do NOT cd the parent session into the worktree.

Fresh worktrees on some projects need setup before the first commit can land — hooks armed in the main checkout (lefthook + commitlint via pnpm, Husky-style git hooks) are absent in a new worktree, so the first feature-branch commit fails when the commit-msg hook can’t find its dependencies.

Read the project’s declared verbs from <project-root>/sdlc.yaml under the worktree_init: key (see ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md) and run them via the same executor that powers Step 7’s quality checks. If sdlc.yaml is absent OR worktree_init: is absent or empty, the step is a silent no-op — do NOT invent verbs.

Procedure (in the worktree, immediately after git worktree add, before any other commit attempt):

  1. If <project-root>/sdlc.yaml does not exist, skip — no init needed. (For brand-new projects, /sdlc:setup will create the file later; until then there’s nothing to read.)

  2. Otherwise, run the executor with --key worktree_init --allow-empty:

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run \
    --config <project-root>/sdlc.yaml --key worktree_init \
    --project-root <worktree-root> --log --allow-empty

    --allow-empty turns a missing or empty worktree_init: key into exit 0 with a single stderr warning — that’s the expected outcome on projects with no init recipe. Any non-zero exit code (a declared verb failed) is a hard error: stop and surface to the user, do not proceed to Step 5.

Example: a JS project declares worktree_init: ["mise trust", "just setup-worktree"]; the executor runs them in order so the first commit finds its commit-msg hook armed. A project with no hook to arm omits the key (or sets it to []) and the executor exits 0 with a stderr warning.

5. Ensure the task is implementation-ready, then start it

Section titled “5. Ensure the task is implementation-ready, then start it”

This step is gate-then-start. The readiness gate (/sdlc:task-ensure-ready) runs first; only after it returns ENSURE-READY-OK: does Step 5b move the lease from claimed to working.

Neither half writes status:. Under [[D-S30G-task-state-plane-split]] the run’s phase lives on the lease and frontmatter status: is a derived cache, so a task in flight reads open/ready from pickup to closure. The start commit that used to flip it to in-progress is gone, and so is the separate verify-stamp commit: 5a’s pass writes the lease’s gates when a lease is held, and sdlc lease reconcile heals any file that still carries an execution-plane status.

The one commit Step 5 can still land is a PROMOTION — status flip plus readiness stamp, in one commit — and only when the task entered at a planning/* status. A task picked up at open/ready passes the gate with no commit at all.

Land any task-body edits on origin/main before the gate runs. Under --commit-on main (the shape Step 5 uses), the readiness gate reads the task file from origin/main and lands any commit through an ephemeral worktree off origin/main — it neither inspects nor touches the author’s checkout or this worktree ([[D-WK7T-agent-git-writes-worktree-isolated]]). So a promotion commit builds on whatever origin/main carries, NOT on uncommitted worktree edits. If Step 2’s relevance check produced a task-body edit that must persist (a moved-file note, a clarified AC), land it on origin/main before invoking 5a; an edit left only in this worktree is discarded by Step 5b’s reset --hard origin/main. (The body-edits precondition that exit-4s on the standalone --commit path does NOT fire under --commit-on main — the off-origin worktree is always clean. See [[T-XBJY-ensure-ready-refuses-with-unstaged-body-edits]].)

Invoke /sdlc:task-ensure-ready against the absolute path to the task file inside the worktree, passing both --commit-on main and --cleanup-on-fail. The first flag lands the readiness-stamp (PASS) or downshift (NEEDS-DEFINITION) commit on origin/main through an ephemeral worktree off origin/main — never this checkout ([[D-WK7T-agent-git-writes-worktree-isolated]]) — so the worktree’s task file is left untouched and the task branch carries implementation diff only. The second flag extends the FAIL path so the script ALSO tears down the abandoned worktree + branch + lease before exiting. (Task-state lands on origin/main, not the task branch, so Step 5b’s reset onto the new tip has no frontmatter to rebase — see [[T-SIHV-task-state-frontmatter-commits-on-main-not-worktree-branch]], [[T-XM0B-ensure-ready-script-emits-markers-and-cleanup]].)

The sub-skill’s marker on stdout will be ENSURE-READY-OK: <basename> or ENSURE-READY-NEEDS-DEFINITION: <basename>. The skill itself shells the mutator with --commit-on main --cleanup-on-fail; you do not invoke ensure_ready_mutate.ts directly here — the canonical entry point is the /sdlc:task-ensure-ready skill. The script emits the marker on its own; do not re-print it.

autonomy: autonomous/pr self-ready. If the resolved task is autonomy: autonomous/pr, /sdlc:task-ensure-ready’s own Step 3a autonomy gate makes one best-effort /sdlc:task-auto-define pass and re-verifies before returning a verdict. From task-work’s side nothing changes: read the single terminal marker. On a closed gap the task self-readies to ENSURE-READY-OK: and Steps 5b–10 carry it to an open PR (never auto-merged — the human stays at the merge gate). If auto-define can’t close the gap, the marker is ENSURE-READY-NEEDS-DEFINITION: and the cleanup path below fires.

The ENSURE-READY-OK: marker is intermediate, not terminal. It signals that the readiness gate passed and Step 5a is done — Steps 5b through 10 still need to execute. The only stdout line that means “the whole /sdlc:task-work flow finished successfully” is the final-marker line emitted at the end of Step 10 (see “Final marker” below). Do NOT treat ENSURE-READY-OK: as task-work’s verdict and skip Steps 5b–10 — that short-circuit cost two runs on 2026-05-28 (the then-bare READY: marker; see [[T-ICA5-task-work-sub-agent-verdict-contract-clarity]]).

If /sdlc:task-ensure-ready returned ENSURE-READY-NEEDS-DEFINITION: <basename>, the downshift commit has ALREADY landed on main (via --commit-on main), AND the worktree + branch + lease have ALREADY been torn down (via --cleanup-on-fail). The script’s third stdout line carries the cleanup state — cleaned-up: worktree=<state> branch=<state> lease=<state> — for diagnostic relay. Main correctly reports status: planning/needs-definition with the definition_gap: field populated; the consuming orchestrator’s next pickup pass will see the task as ineligible until a human (or a follow-up /sdlc:task-define session) fills the gap.

All this task-work needs to do at this point is:

  1. Stop the heartbeat if Step 6 has started one (typically it has not at this point, but be defensive):

    if [ -f .sdlc/runtime/lease-heartbeat-<basename>.pid ]; then
    kill $(cat .sdlc/runtime/lease-heartbeat-<basename>.pid) 2>/dev/null
    rm -f .sdlc/runtime/lease-heartbeat-<basename>.pid
    fi
  2. Emit TASK-WORK-NEEDS-DEFINITION slug=<basename> as the terminal verdict (see “Final verdict” below) and exit. Do NOT proceed to Step 5b, Step 6, or any other step.

If the user fills the gap later via /sdlc:task-define and re-runs /sdlc:task-work <basename>, a fresh worktree + branch + lease are created from scratch in Step 4 and Step 2a.

If the marker was ENSURE-READY-OK: <basename>, the gate passed; proceed to Step 5b. Its plane: line says where the result went — lease-gates on the normal task-work path, frontmatter-promotion when the task entered at a planning/* status, none when it was already promoted and clean.

If the marker was ENSURE-READY-PARENT-ROLLUP: <basename>, the task is a parent rollup, not a dispatchable work order. Stop: there is nothing here to implement. Report to the user and release the lease.

Step 5b is safe to re-run on a resumed session — the lease transition is a no-op when the phase is already working.

The sub-skill owns every write for the readiness_verified_at: / status: / definition_gap: fields, on whichever plane it chose; do not commit them again here.

5b. Start the run — lease transition, reset the task branch

Section titled “5b. Start the run — lease transition, reset the task branch”

Only reached when 5a returned ENSURE-READY-OK:. Starting a run writes nothing to main: it moves the lease from claimed to working and resets the task branch (in the worktree) onto the current origin/main tip.

Shell out to ${CLAUDE_PLUGIN_ROOT}skills/task-work/start_task.ts:

bun run ${CLAUDE_PLUGIN_ROOT}skills/task-work/start_task.ts \
<absolute path to task file in the worktree> \
--worktree <absolute worktree path> \
--branch task/<basename> \
--lease-authority <authority resolved from sdlc.yaml or env>

The script reads origin/main’s copy of docs/planning/tasks/<basename>.md and refuses a task that is not eligible to start (a closed/* status). It then CAS-REPLACEs the lease ref from claimed to working (owner-preserving — lease_token does not rotate), and finally git reset --hard origin/main inside the worktree so the task branch fast-forwards to the current origin/main tip. It emits STARTED: <basename>.

What it no longer does, and why. It used to commit status: in-progress + last_reviewed + a ## Post-mortem stub to main — the same fact the lease transition on the next line already recorded, written twice ([[D-S30G-task-state-plane-split]]). The status flip is gone because the lease is authoritative; the last_reviewed bump went with it (review recency is not a per-run fact); and the post-mortem stub moved to close-commit, which plants it at closure when the body lacks one. Frontmatter reads open/ready throughout.

The lease-authority value is the same lease_authority: configured in <project-root>/sdlc.yaml (or the SDLC_LEASE_AUTHORITY env override the lease library honors). Step 2a’s acquire_lease() call already resolved it; pass the same string through to start_task.ts so the CAS-REPLACE targets the same authority that holds the claimed ref.

Exit codes the caller must dispatch on:

  • 0 — lease transitioned to working and the task branch reset onto the current origin/main tip. Proceed to Step 6.
  • 1 — a precondition failed (task not eligible on origin/main — e.g. already closed/* — the task file is absent on origin/main, lease library unimportable, the active lease ref is in an unexpected phase for a start transition, or the worktree fetch/reset --hard origin/main step failed). The error message names the precondition; surface to the user and stop.
  • 2 — bad arguments. Almost always a caller bug; fix the invocation.
  • 4LEASE-TRANSITION-FAILED ref=<ref> on stderr. The CAS-REPLACE was rejected (another worker moved the ref between fetch and push, or schema validation failed). Stop and surface to the user — do not retry blindly; the conflicting transition needs human triage.

(Exit code 3 is not emitted by start_task.ts; the slot is reserved.)

The script never touches this checkout’s working tree, index, or HEAD. Uncommitted edits in the task worktree, however, are discarded by the reset --hard origin/main; Step 5 runs before implementation work, so the worktree is expected clean here.

Idempotency: a lease already at working transitions as a no-op, so a resumed session that previously completed 5b can re-invoke safely (the reset still runs to confirm the task branch is in sync).

Launch a sub-agent with the Agent tool. Brief it like a colleague who just walked in:

  • Tell it the absolute path to the task file.
  • Tell it the absolute path to the worktree it must operate in.
  • Tell it the branch name and that it must commit on that branch.
  • Tell it the acceptance criteria are the contract — every AC must be satisfied.
  • Tell it to run the project’s quality checks after substantive changes (via ${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run --config <project-root>/sdlc.yaml --line) and to fix issues before reporting done.
  • Pick subagent_type appropriately: general-purpose for mixed work, Plan first if the approach is still vague.

If the task is large, break it into waves and brief sequential sub-agents — but each sub-agent must leave the worktree in a clean, committed state before the next runs.

The lease’s expires_at window is finite (default one hour). Implementation can take longer than one TTL window, and working past expires_at risks another worker stealing the lease mid-side-effect. Background a heartbeat-loop script that issues a CAS-REPLACE every TTL/2 seconds for the duration of the sub-agent’s run.

Start the heartbeat in the background BEFORE dispatching the implementation sub-agent. Use absolute paths so the PID file lands inside the worktree’s .sdlc/runtime/ directory (create it first if it doesn’t exist):

mkdir -p .sdlc/runtime
${CLAUDE_PLUGIN_ROOT}cli/sdlc lease heartbeat-loop start <basename> 2>>.sdlc/runtime/lease-heartbeat-<basename>.log &
echo $! > .sdlc/runtime/lease-heartbeat-<basename>.pid

The script’s stderr (one HEARTBEAT ref=<ref> expires_at=<rfc3339> line per tick) lands in the log file so the parent shell stays quiet; the PID file is the target to stop the loop later.

Dispatch the implementation sub-agent via the Agent tool.

When the Agent invocation returns (success, failure, or exception), always stop the heartbeat — the try/finally-shaped pairing is load-bearing:

kill $(cat .sdlc/runtime/lease-heartbeat-<basename>.pid) 2>/dev/null
rm -f .sdlc/runtime/lease-heartbeat-<basename>.pid

The kill sends SIGTERM (no -9) so the loop exits cleanly at its next tick boundary. The 2>/dev/null swallows the race where the loop already exited on its own (e.g. another worker advanced the ref).

If the parent skill itself crashes before reaching the kill, the heartbeat process will keep ticking until the next CAS round-trip fails or until the operator notices and kills it. That’s the intentional fail-safe — losing the parent should not silently abandon the lease in a not-being-heartbeated state. The PID file under .sdlc/runtime/ makes orphan recovery one kill $(cat ...) away.

The sub-agent dispatch above is the preferred path. When the Agent tool is genuinely unavailable in the current context (a harness that doesn’t honour allowed-tools:, or an environment not granted sub-agent fan-out), the parent implements inline rather than failing or silently improvising.

When the fallback applies. Only when the Agent tool is genuinely unavailable. If Agent is available, dispatch to a sub-agent — do not pick the fallback for convenience.

What the parent must still do. Every downstream obligation of the sub-agent path stays in force when the parent implements inline:

  • Run Step 7’s local quality checks against <project-root>/sdlc.yaml, fix any failures, and re-run until the executor reports OK. Do not skip the gate just because no sub-agent boundary forced it.
  • Run Step 8’s post-mortem — both the AC-coverage section and the spawn-from-post-mortem dispatch. The spawn step itself launches a sub-agent; if Agent is unavailable there too, follow this same fallback contract for that dispatch (read ${CLAUDE_PLUGIN_ROOT}entities/task/spawn-from-post-mortem.md and execute its procedure inline).
  • Emit the same Step 10 terminal stdout marker (TASK-WORK-COMPLETE: <basename> PR=<pr-url>) the sub-agent path would have. The marker is the contract; the parent-vs-sub-agent origin of the implementation is invisible to orchestrators.

What discipline is required. Hold to the sub-agent path’s structure; do not collapse the work into a single sprawling change:

  • Treat each AC as a discrete commit on the feat branch when the ACs are logically separable — a sequence of focused changes, not a single monolithic diff.
  • Do not skip the quality-check loop because “you can see the code is fine” — the gate catches issues the implementer’s own judgment misses.
  • Do not collapse the Step 8 post-mortem into an inline summary in the PR description. It is a separate commit with a specific structure (AC coverage, what worked, friction and automation gaps).

The set of gates is per-project configurable via <project-root>/sdlc.yaml (shape documented at ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md). This skill does not hard-code any specific runner — instead it shells out to the shared executor, which reads the project’s quality_checks: list and runs each verb in order.

In the worktree:

  1. Invoke the executor, passing the SHA captured in Step 3a so the gate only fails on drift this branch introduced:

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run \
    --config <project-root>/sdlc.yaml \
    --diff-against-baseline "$ORIGIN_MAIN_SHA" \
    --line

    ($ORIGIN_MAIN_SHA is the local shell variable set in Step 3a. --line is the default; --log is incompatible with --diff-against-baseline because the gate needs to capture each verb’s stdout to compute the per-verb finding diff — the executor will warn and coerce to --line if you pass --log here.) Do not pipe the executor’s output to head/tail if you’re gating on its exit code — see ${CLAUDE_PLUGIN_ROOT}skills/CLAUDE.md (“Don’t pipe commands you gate on”).

    If Step 3a skipped baseline capture (no quality_checks: configured), drop --diff-against-baseline from the invocation — the executor’s missing-config branch handles that case the same way it always has.

    With --diff-against-baseline set, the executor surfaces pre-existing drift on stderr prefixed pre-existing: <verb>: <line> (visible but non-gating) and new drift prefixed new-drift: <verb>: <line> (visible AND gating). Pre-existing findings are informational — the operator should NOT triage them here; the gate’s job is solely to flag drift this branch introduced.

    Three outcomes, all caller-visible:

    • sdlc.yaml is absent. The executor reports config file not found: <path> on stderr and exits 2. Treat that as a project-setup gap, not a quality-gate failure: surface a clear message to the user (“sdlc.yaml not found at <path> — run /sdlc:find-quality-checks to populate it, or /sdlc:setup to scaffold an empty one”), then skip the gate and continue. Do not silently substitute hard-coded verbs.
    • sdlc.yaml exists but quality_checks: is empty or missing. The executor prints warning: no quality_checks configured in <path> on stderr and exits 2. Same treatment: surface the warning, skip the gate, continue.
    • sdlc.yaml declares one or more verbs. The executor runs each; stdout shows OK <passed>/<total> on success or FAIL <first-failed-cmd> on failure. On FAIL, re-run with --log to see the failing verb’s output, fix, and re-invoke. Do not proceed to the PR until the executor reports OK.

Pre-PR dogfood (when verb authoring is in scope)

Section titled “Pre-PR dogfood (when verb authoring is in scope)”

This subsection applies only to tasks whose ## Files to touch includes the quality service’s runner/baseline core (solutions/ontological/lib/services/quality/) or any new/modified quality-check verb (sdlc quality …). Skip it entirely for tasks that don’t author or change a gate verb.

The formal gate (sub-step 1) runs only against the worktree’s modified tree, so a verb with the wrong stdout shape (too eager → false new-drift:; too chatty → a flood of pre-existing: lines) passes it while still miscalibrated. Dogfood the verb against origin/main’s tree before the PR is open: capture a baseline at the merge-base SHA and diff HEAD against it.

${CLAUDE_PLUGIN_ROOT}cli/sdlc quality baseline capture "$ORIGIN_MAIN_SHA" \
--config <project-root>/sdlc.yaml \
--baseline-dir <project-root>/.sdlc/quality-baselines
${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run \
--config <project-root>/sdlc.yaml \
--diff-against-baseline "$ORIGIN_MAIN_SHA" --line

The diff splits findings into pre-existing: (already on origin/main) and new-drift: (introduced by HEAD). A clean dogfood is zero new drift and a pre-existing count low enough to gate on usefully; any new-drift: line means the verb is too eager and miscalibrated. When it surfaces new drift, iterate on the verb’s output shape until the run is clean, then proceed. This is an authoring affordance run manually — it is not a second gate, and Step 7’s formal gate (sub-step 1) stays the gate.

  1. Walk each AC from the task file; confirm explicitly that it’s satisfied. If you can’t verify an AC programmatically (UI feel, copy choice, etc.), state that and ask the user to spot-check.
  2. For UI work, follow the user’s memory rule: verify in dev server AND Storybook in a browser, not just the quality-checks pass.

If any AC can’t be met, do not open a PR. Add a <blocked></blocked> section to the task file describing what’s stuck and why, commit it on the feature branch, transition the lease to the blocked phase, and report back. Blocked is a phase, not a status: it is execution state, so it lives on the lease ([[D-S30G-task-state-plane-split]]) and frontmatter stays open/ready.

${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task transition <basename> --phase blocked

8. Post-mortem — capture AC coverage and automation gaps

Section titled “8. Post-mortem — capture AC coverage and automation gaps”

Before opening the PR, while the implementation context is still fresh, fill the ## Post-mortem stub that Step 5b already appended to the end of the task file in the worktree. The section’s placement (end of body) and structure (the H2, the caption, and the three H3 subsections) are already materialized by start_task.ts — you replace each subsection’s _TBD — filled at Step 8._ placeholder with the real content below. Write it through one lens: automate as much as possible — record where this run needed a human.

Do not skip this step even on smooth runs — a clean post-mortem is still data (“nothing got in the way, fully auto-verified”).

The stub already carries this exact structure — fill each subsection in place. The headings below are the source of truth for the stub’s content:

## Post-mortem
_Captured by /sdlc:task-work on <YYYY-MM-DD>. PR: <#NNN or "pending">._
### Acceptance criteria coverage
For each AC in the task, classify it as one of:
- `auto` — verified by an automated check (test, lint, type-check, schema validator).
- `agent-manual` — the agent exercised the behavior directly in the worktree (ran a CLI, hit an endpoint, inspected output) and confirmed it.
- `deferred-user` — left for the human to spot-check (UI feel, copy, visual layout, anything not programmatically observable).
One bullet per AC, in original order, naming the verification:
- AC-1: auto — `cargo test date_range::`
- AC-2: agent-manual — ran `just app -- --foo bar`; output matched the spec
- AC-3: deferred-user — visual check on the dashboard at /admin/foo
### What worked
Short bullets — what in the automated flow nailed it without friction. Keep these honest; if nothing stands out, write `- nothing notable`.
### Friction and automation gaps
The actionable part. For each spot the run needed human help, retries, or judgment calls that the skill couldn't make on its own, write one bullet:
`<symptom> — <what automation, tooling, or skill change would close this gap next time>`
Examples:
- Quality checks failed twice on missing lefthook hook — worktree init step should also verify `git config core.hooksPath`
- Had to ask user whether to bump the schema version — task spec should require a version-bump decision in the `Approach` section
- Sub-agent picked the wrong helper module; only caught in review — a pre-implementation grep for similar helpers would prevent this
If nothing went sideways, write `- none observed`. Resist the urge to invent gaps; a clean run is fine.

After writing the section, in the worktree (parameter contract: sdlc commit get-schema task-lifecycle):

git add docs/planning/tasks/<basename>.md
${CLAUDE_PLUGIN_ROOT}cli/sdlc commit create --kind task-lifecycle --data - <<'EOF'
{"action": "post-mortem", "basename": "<basename>"}
EOF

Keep this as its own commit — it’s reflective, not part of the implementation. The next step (sync) will rebase it onto origin/main along with the rest of the feat branch.

Spawn follow-up tasks from the friction list

Section titled “Spawn follow-up tasks from the friction list”

Turn the friction bullets into actionable tasks. Delegate that conversion to a sub-agent.

Always invoke this sub-step — even when the gaps section is empty, its no-op path emits an explicit one-line report so the PR body and orchestrator logs see the same shape every run. The procedure (and the contract for what the sub-agent must do) lives in ${CLAUDE_PLUGIN_ROOT}/entities/task/spawn-from-post-mortem.md. The sub-agent reads that doc; you do not inline it.

Before the fast-path check, read this project’s resolved spawn policy (no pipe — gate on the bare exit code, per ${CLAUDE_PLUGIN_ROOT}skills/CLAUDE.md):

${CLAUDE_PLUGIN_ROOT}cli/sdlc config get-spawn-policy

It prints { "enabled", "drive_to_ready", "fallback_status", "pr_grouping", "pr_title_pattern" } (all defaults filled; pr_title_pattern is null when unset; see ${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.md). Then:

  • If enabled is false, skip spawning entirely. The post-mortem was already written and committed above; emit this one-line no-op report inline and proceed to Step 9:

    SPAWN-DISABLED: task.execution.spawn_from_post_mortem.enabled=false — no follow-ups spawned
  • If enabled is true, carry drive_to_ready and fallback_status into the sub-agent dispatch below (they flow through spawn-from-post-mortem.md to /sdlc:spawn-task-pr), and continue.

Before dispatching the sub-agent, parse the ### Friction and automation gaps subsection of the post-mortem you just committed. If the runner can statically determine the list is empty — the only bullets are - none observed, OR the subsection has no bullet lines at all — skip the sub-agent dispatch entirely and emit the no-op report inline:

SPAWNED-LOCAL: 0
SPAWNED-CROSS-REPO: 0
LINKED-EXISTING: 0
SKIPPED: 0
CLASSIFICATION-FAILED: 0
PATHS:

This is exactly the shape the sub-agent would have returned per spawn-from-post-mortem.md step 7’s report contract. Proceed directly to Step 9 — there is no new commit to make.

If the friction list has any non-empty, non-- none observed bullet, fall through to the sub-agent dispatch below.

If Agent is unavailable in the current context, follow the Step 6 fallback contract (“Fallback: inline implementation”) for this dispatch as well: read ${CLAUDE_PLUGIN_ROOT}/entities/task/spawn-from-post-mortem.md and execute its procedure inline, holding to the same discipline (treat each spawned task as its own commit, do not collapse the reporting block).

Launch a sub-agent with the Agent tool (subagent_type: general-purpose) and brief it like this — keep the brief short, since the procedure is in the reference doc:

You are spawning follow-up tasks from a /sdlc:task-work post-mortem.
Read and follow the procedure in
${CLAUDE_PLUGIN_ROOT}/entities/task/spawn-from-post-mortem.md
exactly. Use these inputs:
- Originating task file: <absolute path inside the worktree>
- Worktree root: <absolute worktree path>
- Feature branch: task/<basename>
- Plugin root: ${CLAUDE_PLUGIN_ROOT}
- Today's date (UTC): <YYYY-MM-DD>
- Spawn policy: drive_to_ready=<value>, fallback_status=<value>,
pr_grouping=<value>, pr_title_pattern=<value-or-null>
(drive_to_ready/fallback_status forward to /sdlc:spawn-task-pr on every
dispatch; pr_grouping/pr_title_pattern drive PR packaging per step 4a-bis)
Return the structured report described in the procedure's "Commit
and report" step (the SPAWNED/LINKED-EXISTING/SKIPPED/PATHS block,
or ERROR: <reason> on unrecoverable failure).

After the sub-agent returns:

  • If it reported ERROR:, surface the error to the user and ask whether to skip follow-up generation and proceed to Step 9, or stop and let the user investigate. Do not silently swallow the failure.
  • If it reported SPAWNED: 0 with no linked-existing entries, that’s fine — proceed to Step 9.
  • Otherwise, mention the count and paths to the user in one line (“Spawned 2 follow-up tasks, linked 1 existing — see post-mortem”), then proceed to Step 9.

The new task files are now part of the task branch and will ride along in the same PR. Step 10’s PR body should mention them under a ## Follow-up tasks spawned section if any exist.

Fallback: the Agent tool isn’t available

Section titled “Fallback: the Agent tool isn’t available”

When the Agent tool is unavailable (no Agent/Task invocation surface, OR a dispatch attempt returns an explicit “tool unavailable” error), inline the procedure rather than skipping the sub-step or failing the run.

The procedure to inline is exactly ${CLAUDE_PLUGIN_ROOT}/entities/task/spawn-from-post-mortem.md, executed by the runner in the worktree against the same inputs that would have been passed to the sub-agent (originating task path, worktree root, feature branch name, plugin root, today’s UTC date, and the spawn policy drive_to_ready / fallback_status read above). The runner reads that doc and follows its steps 1–7 directly. The terminal output of the inlined procedure is the same SPAWNED-LOCAL: <N> / SPAWNED-CROSS-REPO: <X> / LINKED-EXISTING: <M> / ... report block defined in step 7 — surfaced inline to the runner’s own stdout, not returned through a sub-agent boundary.

After the inlined procedure finishes, treat its report exactly the same way the post-sub-agent branch above treats the sub-agent’s report (zero/non-zero/ERROR cases), then proceed to Step 9.

9. Sync with origin/main before opening the PR

Section titled “9. Sync with origin/main before opening the PR”

Multiple /sdlc:task-work sessions can run in parallel, each landing a chore(tasks): start <other-basename> commit on local main (and a docs(tasks): verify <other-basename> implementation-ready commit ahead of it via the same path). Because Step 5b commits on main and Step 4 branched from main (with Step 5b’s reset pulling the new tip back into the task branch), your task branch may now have unrelated start-commits / verify-commits as ancestors that landed on main between Step 4 and Step 5b. If you push as-is, those commits show up in your PR diff and couple this PR to other in-flight tasks.

Always run this step before pushing. Even if no parallel work happened, this is a cheap no-op.

  1. git fetch origin main.

  2. Classify task-branch ancestry against origin/main via the co-located helper:

    bun run ${CLAUDE_PLUGIN_ROOT}skills/task-work/check_ancestry.ts --this-basename <basename>

    The script prints one of:

    • clean — every commit in origin/main..HEAD either belongs to this task (its start-commit, verify-commit, work commits) or isn’t a chore(tasks): start <other> commit at all, AND the branch’s two-dot diff against origin/main introduces no files the branch didn’t author. Push as-is.
    • contaminated: [<other-basename-1>, ...] last_sha=<sha> — one or more foreign start-commits from parallel sessions sit between origin/main and your own start-commit. <sha> is the most recent contaminating commit, suitable for the git rebase --onto call below.
    • stale-base: <N> upstream-drift files; rebase onto origin/main — no foreign start-commits ride along (the parallel-task walk is clean), BUT local main was fast-forwarded by an externally-merged PR after this branch was cut, so the branch is rooted off a stale commit and its two-dot diff against origin/main carries <N> restructured upstream files the branch never touched. Pushing as-is would couple the PR to those unrelated upstream changes. The remedy is the plain rebase in step 3b — there is no last_sha to --onto here, because the contamination is a stale fork-point, not foreign commits in the history.

The helper turns Step 9’s prior “eyeball the log” judgment call into a deterministic check. Its docstring covers both heuristics — the parallel-task walk (matches only chore(tasks): start <basename> subjects) and the stale-base diff-scope check (two-dot minus three-dot diff against origin/main) — and their scope limits. 3a. If the helper reported contaminated:, rebase to drop the foreign commits:

git rebase --onto origin/main <last_sha> task/<basename>

<last_sha> is the value the helper printed. git rebase --onto X Y branch replays Y..branch onto X — so the commits you want to keep (your start-commit + work) land on top of origin/main, and everything reachable from Y (the foreign start-commits) is dropped. 3b. If the helper reported stale-base:, re-root the branch onto the current origin/main tip so the stale upstream files drop out of the diff:

git rebase origin/main task/<basename>

A plain git rebase origin/main replays your own commits onto the fresh origin/main tip; once your branch shares that base, the restructured upstream files are already present in the base and no longer appear as branch-introduced diff. (Use git rebase --onto origin/main <last_sha> only for the contaminated: case — there a specific foreign-commit span must be excised; the stale-base case has no foreign commits, only a stale fork-point.) 4. If origin/main itself has moved forward since you branched (someone merged a PR), the rebase may surface conflicts. Resolve them like any rebase conflict; do not --skip your own commits. If the conflicts are non-trivial, stop and ask the user. 5. After the rebase, re-run the helper to confirm it now reports clean. 6. Re-run the project’s quality checks

${CLAUDE_PLUGIN_ROOT}cli/sdlc quality run --config <project-root>/sdlc.yaml --diff-against-baseline "$ORIGIN_MAIN_SHA" --line

— origin/main may have moved. The same baseline-gated invocation as Step 7; pre-existing drift stays informational, only new drift gates. 7. The task branch is now ready to push. The push will be a fresh branch (first push) or a force-push if you had previously pushed pre-rebase (use git push --force-with-lease, never plain --force).

This step is the terminal action of /sdlc:task-work. It pushes the branch, opens the PR with the lease-binding footer, transitions the lease ref to awaiting-review via the CLI’s task transition subcommand (which CAS-REPLACEs and records the PR number), and exits with the final marker. The downstream /sdlc:pr-respond and /sdlc:task-close-out skills take over from here ([[T-SSB8-split-pr-respond-and-close-out-with-lease-reacquire]]).

The background heartbeat process from Step 6 must already have been stopped (via the kill $(cat .sdlc/runtime/lease-heartbeat-<basename>.pid) invocation that pairs with the start). The lease ref is at phase: working at this point.

  1. git push -u origin task/<basename> (or git push --force-with-lease if re-pushing after rebase).

  2. gh pr create with:

    • Title: <task headline> (truncate to under 70 chars).

    • Body composed via mktemp + quoted-heredoc + gh pr create --body-file <tmp> so the lease footer’s HTML comment survives shell quoting:

      ## Summary
      <2-3 bullets on what changed and why>
      ## Task
      docs/planning/tasks/<basename>.md
      ## Acceptance criteria
      <copy the AC list from the task file, with each box checked
      and a one-line note on how it was satisfied. Cite the post-mortem
      classification in parentheses, e.g. "(auto)", "(agent-manual)",
      "(deferred-user — please spot-check)">
      ## Test plan
      <Mirror the `deferred-user` items from the post-mortem as the
      manual checklist. If the post-mortem has none, write
      "- [ ] nothing requires manual verification — see post-mortem in task file">
      ## Follow-up tasks spawned
      <Only include this section if Step 8's sub-agent reported
      SPAWNED > 0 or LINKED-EXISTING > 0. List each new/linked task
      file by path with a one-line description. Otherwise omit the
      whole section.>
      <!-- sdlc-lease: task=<basename> lease=<lease_id> -->

    The footer is the last line of the body, immediately before EOF. No surrounding whitespace, no trailing newline beyond the standard one gh pr create appends. Format is exact: <!-- sdlc-lease: task=<basename> lease=<lease_id> -->. The HTML comment is invisible in the rendered PR but greppable from gh pr view --json body. /sdlc:pr-respond reads this footer to verify the right task on resume.

  3. Capture the PR number from the gh pr create output (the URL ends in /pull/<N>).

  4. Do NOT write the PR to the task file. Under the [[D-S30G-task-state-plane-split]] plane rule the live PR binding is the lease’s pr_number, set by the transition in Step 10.5; prs: is a historical record written once, in the terminal close commit ([[T-IVEJ-prs-once-at-close]]). /sdlc:task-close-out gathers every PR the task produced and passes each through close-commit’s repeatable --pr-url. Go straight to Step 10.5.

    The crash window between gh pr create and the lease transition is accepted: an unbound PR is discoverable by branch name, which the probe’s open-PR lookup already does, and sdlc lease reconcile reports it under pr-footer-missing-or-mismatched.

  5. Compose the handoff inputs and transition the lease to awaiting-review in one shell-out. The CLI’s task transition subcommand composes handoff.md (via the library’s compose_handoff_md helper) and embeds it into the lease commit’s tree alongside lease.json — downstream /sdlc:pr-respond / /sdlc:task-close-out workers recover the blob via git show <lease-ref>:handoff.md. The composition and the CAS-REPLACE are a single atomic operation; there is no intermediate “lease moved but handoff not yet written” state.

    First, capture the inputs the handoff body needs:

    • <summary> — a single-line description of what was implemented (from the implementation sub-agent’s return value).
    • <pr-url> — the URL returned by gh pr create in Step 10.2.
    • <quality-status> — the result of Step 7’s quality checks (e.g. OK 12/12 or 1 FAIL: foo.py).
    • The files-changed table — pass --handoff-files-changed-from-diff origin/main in the call below so the op derives it. (The legacy --handoff-files-changed <path> flag still reads a caller-written manifest; the two are mutually exclusive — passing both errors with HANDOFF-FILES-SOURCE-CONFLICT.)
    • <followups-path> — a tempfile listing spawned follow-up task slugs, one per line. If Step 8’s spawn dispatch reported SPAWNED-LOCAL: 0 and LINKED-EXISTING: 0, write an empty file (the CLI renders the section with a single none bullet) — do not omit the flag in the call below.

    Then transition with the handoff embedded:

    ${CLAUDE_PLUGIN_ROOT}cli/sdlc lease task transition <basename> \
    --phase awaiting-review --pr-number <N> \
    --handoff-summary "<summary>" \
    --handoff-pr-url "<pr-url>" \
    --handoff-quality-status "<quality-status>" \
    --handoff-files-changed-from-diff origin/main \
    --handoff-followups <followups-path>

This sets phase: awaiting-review with expires_at: null (a non-heartbeating placeholder state — review can take days) and records pr_number; the lease_token is preserved on this transition (see [[T-1VF3-lease-token-rotation-full-policy]]).

Exit codes the caller must dispatch on:

  • Exit 0 — lease transitioned; handoff.md is in the lease commit tree. Proceed to Step 10.6.
  • Exit 1HANDOFF-REQUIRED message="..." on stderr means one of the three required textual flags (--handoff-summary / --handoff-pr-url / --handoff-quality-status) is missing or empty. Fill it in and re-invoke; do NOT skip the gate by dropping back to a flag-less call. Other exit-1 cases (library unimportable, authority unreachable) name themselves on stderr.
  • Exit 2CAS-FAILED: another worker moved the ref between Step 2a’s acquire and this transition. Surface to the user; do not retry blindly.
  • Exit 3LEASE-EXPIRED: the lease’s expires_at passed before this call. Step 6’s heartbeat should have prevented this; if it fires, something interrupted the heartbeat loop. Surface and stop.
  1. Return the PR URL to the user.

Final marker — required terminal stdout line

Section titled “Final marker — required terminal stdout line”

The successful end of Step 10 MUST emit one final stdout line in this exact shape as the last line of the run:

TASK-WORK-COMPLETE: <basename> PR=<pr-url>

<basename> is the task filename without .md. <pr-url> is the full https://github.com/... URL returned by gh pr create. This is the only marker that signals the entire /sdlc:task-work flow finished successfully. Its absence is unambiguous evidence the sub-agent stopped early (typically by mistaking the intermediate ENSURE-READY-OK: marker from Step 5a for terminal output).

Emit this marker only on the success path through Step 10. The failure paths have their own terminal output:

  • A run that bails because Step 5a returned ENSURE-READY-NEEDS-DEFINITION ends by re-emitting that sub-skill marker — and /sdlc:task-work reports TASK-WORK-NEEDS-DEFINITION slug=<basename> as its own terminal verdict. Do not append the TASK-WORK-COMPLETE: marker on top.
  • A run that bails at Step 2a because of LEASE-CONFLICT ends with the structured stderr line and no success marker.
  • A run that hits the failure-modes section and writes a <blocked></blocked> section ends by reporting the blocked state to the user — again, do not append the success marker.

Orchestrators dispatching /sdlc:task-work should grep their sub-agent’s captured stdout for ^TASK-WORK-COMPLETE: before trusting any “I finished” claim in the sub-agent’s free-form report.

Final verdict — the only four task-work markers

Section titled “Final verdict — the only four task-work markers”

When /sdlc:task-work runs under an orchestrator dispatch (e.g. /sdlc:orchestrate Step 4), the sub-agent’s terminal output line — the verdict — MUST be exactly one of these four task-work-level markers, and nothing else:

  • TASK-WORK-DONE pr=#<N> — Step 10 completed successfully and a PR is open for review. Always paired with the TASK-WORK-COMPLETE: <basename> PR=<pr-url> stdout marker from Step 10. The lease ref is at awaiting-review with expires_at: null; downstream /sdlc:pr-respond and /sdlc:task-close-out take over from here.
  • TASK-WORK-BLOCKED reason="<why>" — the failure-modes section fired: a <blocked></blocked> section is written and committed on the feat branch, the lease is at phase blocked (which carries expires_at: null, so it will not expire while a human looks at it), and human attention is needed to unblock.
  • TASK-WORK-NEEDS-DEFINITION slug=<basename> — Step 5a’s readiness gate surfaced a gap that wasn’t filled (user bailed from /sdlc:task-define, or the gate still failed after a re-run). The spec needs human attention before pickup.
  • LEASE-CONFLICT ref=<ref> owner=<other-host-id> — Step 2a’s acquire_lease found another host’s pre-existing lease at the ref. No worktree, no branch, no PR. The conflicting owner UUID gives the operator something to grep against the dispatcher’s logs.
  • ERROR reason="<why>" — task-work itself failed before reaching any other verdict (e.g. worktree creation failed, validator missing, start-commit script returned an unexpected non-zero exit). Distinct from TASK-WORK-BLOCKED: TASK-WORK-BLOCKED means the task ran and hit a definable wall; ERROR means task-work’s own machinery broke before it could even classify. ERROR is the one un-prefixed marker by convention across all skills.

The slug-prefixed TASK-WORK-* markers stay distinct from those of the skills /sdlc:task-work invokes (/sdlc:task-ensure-readyENSURE-READY-*, /sdlc:task-defineTASK-DEFINE-*, /sdlc:spawn-task-prSPAWN-TASK-PR-*). See the “Marker-confusion failure mode” note below for the 2026-05-28 incident that drove this naming pass.

Sub-skill markers are NEVER task-work’s final verdict

Section titled “Sub-skill markers are NEVER task-work’s final verdict”

The skills /sdlc:task-work invokes — /sdlc:task-ensure-ready, /sdlc:task-define, and (transitively, via Step 8’s post-mortem spawn) /sdlc:spawn-task-pr — emit their own stdout markers as part of their per-skill contracts. Those markers are intermediate signals for the task-work flow’s internal decisions; they are NEVER acceptable as task-work’s own terminal verdict line:

  • From /sdlc:task-ensure-ready: ENSURE-READY-OK: <basename>, ENSURE-READY-NEEDS-DEFINITION: <basename>, ENSURE-READY-AMBIGUOUS: <candidates>, ENSURE-READY-NO-TASK-FOUND.
  • From /sdlc:task-define: TASK-DEFINE-DEFINED: <basename>, TASK-DEFINE-NO-CHANGES: <basename>, TASK-DEFINE-ALREADY-READY: <basename>.
  • From /sdlc:spawn-task-pr (transitive): SPAWN-TASK-PR-DONE pr=..., SPAWN-TASK-PR-EXISTING pr=..., SPAWN-TASK-PR-REHEARSED branch=....

If a /sdlc:task-work sub-agent emits any of those as its terminal line, it has stopped early — typically by mistaking /sdlc:task-ensure-ready’s ENSURE-READY-OK: (which only means “Step 5a done”) for “task-work done”, or /sdlc:spawn-task-pr’s SPAWN-TASK-PR-DONE for “implementation PR opened”. The orchestrator parsing the verdict should treat such a line as an ANOMALY (see /sdlc:orchestrate Step 4’s validation step), not as success.

The verdict marker IS the contract. Free-form prose after the verdict line is truncated by the orchestrator’s parent-side guard; the verdict line must be machine-parseable on its own.

If anything fails (relevance check, sub-agent stuck, quality checks won’t pass, ACs unmet), do not silently abandon. Edit the task file in the worktree:

  • Add or append to a <blocked></blocked> section with what was tried, what failed, and what an unblock would need.
  • Commit on the feature branch.
  • Transition the lease to the blocked phase (sdlc lease task transition <basename> --phase blocked). Do NOT write a status to frontmatter — blocked is execution state and lives on the lease.
  • Report to the user, including the worktree path so they can pick it up.
  • This skill modifies state on main at most ONCE, and often not at all: Step 5a’s PROMOTION commit, which fires only when the task entered at a planning/* status. Everything else about a run — its phase, its gate results, its PR binding — is lease state ([[D-S30G-task-state-plane-split]]). Only the task file is staged; never sweep in unrelated working-tree edits. The matching close-out commit is owned by /sdlc:task-close-out, not this skill.
  • This project runs many /sdlc:task-work sessions in parallel, so origin/main moves under a running session. The rebase in Step 9 is non-negotiable — never push a feat branch without confirming clean ancestry against origin/main first.
  • When in doubt at any step, ask the user via AskUserQuestion rather than guessing.
  • Committing model-generated messages. See ${CLAUDE_PLUGIN_ROOT}conventions/commit-messages.mdmktemp + quoted-heredoc + git commit -F is the canonical pattern. Avoids zsh-glob hazards on conventional-commit parens and tempfile collisions under parallel sessions.
  • Branch naming. See ${CLAUDE_PLUGIN_ROOT}conventions/branch-naming.mdtask/<basename> for implementations (this skill’s Step 4), docs/<basename> for spec-only PRs, chore/<short-slug> for skill-runtime worktrees. Distinct prefixes prevent collisions across the task lifecycle.
  • Project-local extension. See ${CLAUDE_PLUGIN_ROOT}conventions/project-local-skill-extension.md — Step 1a invokes .sdlc/skill-ext/task-work/step1-post.sh if the consuming project ships one, so project-specific notes at task pickup live under .sdlc/ rather than contaminating this skill’s prose.
  • Marker-confusion failure mode (Step 5a→5b boundary). The most-observed sub-agent failure mode is mistaking the intermediate ENSURE-READY-OK: <basename> marker for the terminal verdict and exiting after Step 5a (the 2026-05-28 incident that drove slug-namespacing; the pre-namespace bare READY: marker). Two defenses: Step 10’s final-marker wording prevents the confusion; Step 2’s resume-detection branch recovers the half-finished worktree when prevention fails.