/sdlc:task-work
Generated from solutions/ontological/skills/task-work/SKILL.md.
Description
Section titled “Description”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.
Allowed tools
Section titled “Allowed tools”ReadWriteEditBashGlobGrepAgentAskUserQuestionSkillWebFetchWebSearch
Source
Section titled “Source”Usage:
-
/sdlc:task-work <slug-or-filename>— work the named task. Match against filename, e.g.time-zone,time-zone-boundary-helpers, or the full2026-05-12-time-zone-boundary-helpers. The explicit form is the author’s opt-in: it works regardless of the task’sautonomy: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, echoNO READY TASKS FOUNDand 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 indocs/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
stageorstage/reasonform:- 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 bystatus:. Thein-progress/in-progress/blockedenum 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.
- Planning (spec still being shaped, not pickable):
-
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.yamlunder thequality_checks:key (a list of shell verbs). See${CLAUDE_PLUGIN_ROOT}conventions/sdlc-yaml.mdfor the shape. Step 7 invokes${CLAUDE_PLUGIN_ROOT}cli/sdlc quality runagainst 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 theworktree_init:key (same shape — a list of shell verbs). Step 4 invokes the same executor with--key worktree_init --allow-emptyso 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.mdfor the resolution spec it implements, orsdlc 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-readyin 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).
1. Resolve the task file
Section titled “1. Resolve the task file”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 JSONcandidates[]and use the picked one. If the user bails, surfaceNO TASK FOUND for "<arg>"and exit. - not-found (
resolved: false,reason: "not-found") — surfaceNO 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).
1a. Project-local extension point
Section titled “1a. Project-local extension point”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.
2. Pre-flight: verify task is workable
Section titled “2. Pre-flight: verify task is workable”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 jsonThe 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-progressorin-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_numbernon-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 isopen/ready— which it is for the task’s whole in-flight life; the split retired the start commit that used to flip it toin-progress. - The probe’s
lease_phaseisclaimedorworking— 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_numberisnull).
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:
- 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
gatesobject, so re-running the gate would be a no-op anyway. - 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).
Relevance check (the important one)
Section titled “Relevance check (the important one)”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:
- Read the task end-to-end.
- Extract every file path referenced in the task (regex roughly
[\w/\-\.]+\.(rs|ts|tsx|vue|js|md|toml|json|sql)(:\d+)?). - For each path, confirm it exists with
ls/Read. Note any missing or moved files. - For any symbol/function names the task mentions as “currently does X”,
command grepthe codebase to confirm they still exist with that shape. (Usecommand greprather than baregrep— some shells aliasgreptorgrewrites that fail on BSD flags.) - If
relevance_notereferences 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 appropriateclosed/<reason>) with acompletion_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.
2a. Acquire the lease
Section titled “2a. Acquire the lease”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:
- Operator-direct invocation (
/sdlc:task-work <slug>against a clean authority): no lease ref exists yet. The callcas_creates a freshclaimedlease whoseowneris this host’shost_id. - Orchestrate-dispatched invocation:
/sdlc:orchestratealreadycas_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 ifowner == current_host_id()returns the inherited payload (same-host inheritance); otherwise it raisesLeaseConflict.
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 thelease_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 ownsdlc lease inspector library call; each lookup is onegit fetchround-trip against the authority, no in-skill state threading required. - Exit 4 —
LEASE-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
LeaseErrorfired. 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.
3a. Capture the quality-check baseline
Section titled “3a. Capture the quality-check baseline”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:
-
Resolve the current
origin/mainSHA:git fetch -q origin mainORIGIN_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 whateverorigin/mainhappens to be later). -
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-diris 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. -
Surface the count to the operator. After capture, read the written JSON and report:
Baseline captured at $ORIGIN_MAIN_SHA: N pre-existing findingswhere
Nis the total number of finding lines across all verbs inverbs.<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. -
If
<project-root>/sdlc.yamlis absent OR declares noquality_checks:verbs, the capture is a no-op: emitBaseline skipped: no quality_checks configuredand proceed without settingORIGIN_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.
3b. Probe for missing sandbox permissions
Section titled “3b. Probe for missing sandbox permissions”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, RustCargo.toml, Pythonuv.lock/poetry.lock/requirements.txt, Gogo.mod) UNION the leading verb token of everyquality_checks:/worktree_init:entry in the project’ssdlc.yaml. A polyglot bun+cargo repo resolves BOTH. Each resolved manager lacking aBash(<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 installmention in a pnpm repo) is at most awarning: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, ANDWrite/Editare both granted for the would-be worktree path. Advisorywarning: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:- Grant via
/config— let the user add the missing permission(s), then re-run the probe. - Proceed anyway — accept that the implementing sub-agent may hit a sandbox denial in Step 6
and handle it via the existing
<blocked>flow. - Abort — stop
/sdlc:task-work; no worktree is created.
- Grant via
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.
4. Create the worktree
Section titled “4. Create the worktree”git worktree add .sdlc/worktrees/<basename> -b task/<basename> mainThe 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.
Initialize the worktree
Section titled “Initialize 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):
-
If
<project-root>/sdlc.yamldoes not exist, skip — no init needed. (For brand-new projects,/sdlc:setupwill create the file later; until then there’s nothing to read.) -
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-emptyturns a missing or emptyworktree_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]].)
5a. Run the readiness gate
Section titled “5a. Run the readiness gate”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]]).
NEEDS-DEFINITION cleanup (script-driven)
Section titled “NEEDS-DEFINITION cleanup (script-driven)”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:
-
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 ]; thenkill $(cat .sdlc/runtime/lease-heartbeat-<basename>.pid) 2>/dev/nullrm -f .sdlc/runtime/lease-heartbeat-<basename>.pidfi -
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.
Continuing on PASS
Section titled “Continuing on PASS”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 toworkingand the task branch reset onto the currentorigin/maintip. Proceed to Step 6.1— a precondition failed (task not eligible onorigin/main— e.g. alreadyclosed/*— the task file is absent onorigin/main, lease library unimportable, the active lease ref is in an unexpected phase for a start transition, or the worktreefetch/reset --hard origin/mainstep failed). The error message names the precondition; surface to the user and stop.2— bad arguments. Almost always a caller bug; fix the invocation.4—LEASE-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).
6. Delegate implementation to a sub-agent
Section titled “6. Delegate implementation to a sub-agent”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-purposefor mixed work,Planfirst 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.
Heartbeat during implementation
Section titled “Heartbeat during implementation”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>.pidThe 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/nullrm -f .sdlc/runtime/lease-heartbeat-<basename>.pidThe 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.
Fallback: inline implementation
Section titled “Fallback: inline implementation”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 reportsOK. 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
Agentis unavailable there too, follow this same fallback contract for that dispatch (read${CLAUDE_PLUGIN_ROOT}entities/task/spawn-from-post-mortem.mdand 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).
7. Local quality checks
Section titled “7. Local quality checks”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:
-
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_SHAis the local shell variable set in Step 3a.--lineis the default;--logis incompatible with--diff-against-baselinebecause the gate needs to capture each verb’s stdout to compute the per-verb finding diff — the executor will warn and coerce to--lineif you pass--loghere.) Do not pipe the executor’s output tohead/tailif 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-baselinefrom the invocation — the executor’s missing-config branch handles that case the same way it always has.With
--diff-against-baselineset, the executor surfaces pre-existing drift on stderr prefixedpre-existing: <verb>: <line>(visible but non-gating) and new drift prefixednew-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.yamlis absent. The executor reportsconfig file not found: <path>on stderr and exits2. Treat that as a project-setup gap, not a quality-gate failure: surface a clear message to the user (“sdlc.yamlnot found at<path>— run/sdlc:find-quality-checksto populate it, or/sdlc:setupto scaffold an empty one”), then skip the gate and continue. Do not silently substitute hard-coded verbs.sdlc.yamlexists butquality_checks:is empty or missing. The executor printswarning: no quality_checks configured in <path>on stderr and exits2. Same treatment: surface the warning, skip the gate, continue.sdlc.yamldeclares one or more verbs. The executor runs each; stdout showsOK <passed>/<total>on success orFAIL <first-failed-cmd>on failure. OnFAIL, re-run with--logto see the failing verb’s output, fix, and re-invoke. Do not proceed to the PR until the executor reportsOK.
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" --lineThe 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.
- 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.
- 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 blocked8. 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”).
What to record
Section titled “What to record”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.Commit the post-mortem
Section titled “Commit the post-mortem”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>"}EOFKeep 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.
Read the spawn policy first
Section titled “Read the spawn policy first”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-policyIt 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
enabledisfalse, 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
enabledistrue, carrydrive_to_readyandfallback_statusinto the sub-agent dispatch below (they flow throughspawn-from-post-mortem.mdto/sdlc:spawn-task-pr), and continue.
Fast-path: empty friction list
Section titled “Fast-path: empty friction list”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: 0SPAWNED-CROSS-REPO: 0LINKED-EXISTING: 0SKIPPED: 0CLASSIFICATION-FAILED: 0PATHS: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.
Sub-agent dispatch (the default path)
Section titled “Sub-agent dispatch (the default path)”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.mdexactly. 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 "Commitand 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: 0with 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.
-
git fetch origin main. -
Classify task-branch ancestry against
origin/mainvia 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 inorigin/main..HEADeither belongs to this task (its start-commit, verify-commit, work commits) or isn’t achore(tasks): start <other>commit at all, AND the branch’s two-dot diff againstorigin/mainintroduces 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 betweenorigin/mainand your own start-commit.<sha>is the most recent contaminating commit, suitable for thegit rebase --ontocall below.stale-base: <N> upstream-drift files; rebase onto origin/main— no foreign start-commits ride along (the parallel-task walk is clean), BUT localmainwas 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 againstorigin/maincarries<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 nolast_shato--ontohere, 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).
10. Open the PR
Section titled “10. Open the PR”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.
-
git push -u origin task/<basename>(orgit push --force-with-leaseif re-pushing after rebase). -
gh pr createwith:-
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>## Taskdocs/planning/tasks/<basename>.md## Acceptance criteria<copy the AC list from the task file, with each box checkedand a one-line note on how it was satisfied. Cite the post-mortemclassification in parentheses, e.g. "(auto)", "(agent-manual)","(deferred-user — please spot-check)">## Test plan<Mirror the `deferred-user` items from the post-mortem as themanual 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 reportedSPAWNED > 0 or LINKED-EXISTING > 0. List each new/linked taskfile by path with a one-line description. Otherwise omit thewhole 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 createappends. Format is exact:<!-- sdlc-lease: task=<basename> lease=<lease_id> -->. The HTML comment is invisible in the rendered PR but greppable fromgh pr view --json body./sdlc:pr-respondreads this footer to verify the right task on resume. -
-
Capture the PR number from the
gh pr createoutput (the URL ends in/pull/<N>). -
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-outgathers every PR the task produced and passes each throughclose-commit’s repeatable--pr-url. Go straight to Step 10.5.The crash window between
gh pr createand the lease transition is accepted: an unbound PR is discoverable by branch name, which the probe’s open-PR lookup already does, andsdlc lease reconcilereports it underpr-footer-missing-or-mismatched. -
Compose the handoff inputs and transition the lease to
awaiting-reviewin one shell-out. The CLI’stask transitionsubcommand composeshandoff.md(via the library’scompose_handoff_mdhelper) and embeds it into the lease commit’s tree alongsidelease.json— downstream/sdlc:pr-respond//sdlc:task-close-outworkers recover the blob viagit 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 bygh pr createin Step 10.2.<quality-status>— the result of Step 7’s quality checks (e.g.OK 12/12or1 FAIL: foo.py).- The files-changed table — pass
--handoff-files-changed-from-diff origin/mainin 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 withHANDOFF-FILES-SOURCE-CONFLICT.) <followups-path>— a tempfile listing spawned follow-up task slugs, one per line. If Step 8’s spawn dispatch reportedSPAWNED-LOCAL: 0andLINKED-EXISTING: 0, write an empty file (the CLI renders the section with a singlenonebullet) — 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.mdis in the lease commit tree. Proceed to Step 10.6. - Exit 1 —
HANDOFF-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 2 —
CAS-FAILED: another worker moved the ref between Step 2a’s acquire and this transition. Surface to the user; do not retry blindly. - Exit 3 —
LEASE-EXPIRED: the lease’sexpires_atpassed before this call. Step 6’s heartbeat should have prevented this; if it fires, something interrupted the heartbeat loop. Surface and stop.
- 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-DEFINITIONends by re-emitting that sub-skill marker — and/sdlc:task-workreportsTASK-WORK-NEEDS-DEFINITION slug=<basename>as its own terminal verdict. Do not append theTASK-WORK-COMPLETE:marker on top. - A run that bails at Step 2a because of
LEASE-CONFLICTends 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 theTASK-WORK-COMPLETE: <basename> PR=<pr-url>stdout marker from Step 10. The lease ref is atawaiting-reviewwithexpires_at: null; downstream/sdlc:pr-respondand/sdlc:task-close-outtake 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 phaseblocked(which carriesexpires_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 fromTASK-WORK-BLOCKED:TASK-WORK-BLOCKEDmeans the task ran and hit a definable wall;ERRORmeans task-work’s own machinery broke before it could even classify.ERRORis 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-ready → ENSURE-READY-*, /sdlc:task-define → TASK-DEFINE-*,
/sdlc:spawn-task-pr → SPAWN-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.
Failure modes — capture context
Section titled “Failure modes — capture context”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
blockedphase (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
mainat most ONCE, and often not at all: Step 5a’s PROMOTION commit, which fires only when the task entered at aplanning/*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-worksessions in parallel, soorigin/mainmoves under a running session. The rebase in Step 9 is non-negotiable — never push a feat branch without confirming clean ancestry againstorigin/mainfirst. - 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.md—mktemp+ quoted-heredoc +git commit -Fis 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.md—task/<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.shif 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 bareREADY: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.