T-XM0B-ensure-ready-script-emits-markers-and-cleanup
Status: closed/done · Impact: high · Complexity: small
PR #165 (marker namespacing) renamed task-ensure-ready’s terminal markers from bare READY: /
NEEDS-DEFINITION: to slug-prefixed ENSURE-READY-OK: / ENSURE-READY-NEEDS-DEFINITION: to remove
the collision class with task-work’s own terminal verdicts. PR #167 (state-on-main) added a
NEEDS-DEFINITION cleanup path: when ensure-ready returns the fail marker, task-work tears down the
worktree + branch + lease before returning its own TASK-WORK-NEEDS-DEFINITION verdict.
Both fixes are LLM-prose-driven, which makes them per-dispatch non-deterministic. Observed on
2026-05-28 during a single orchestrator tick: two parallel task-work dispatches against open/ready
tasks that both surfaced a definition gap produced different outcomes. One sub-agent followed the
new flow correctly (emitted TASK-WORK-NEEDS-DEFINITION slug=..., tore down its
worktree+branch+lease). The other emitted the bare NEEDS-DEFINITION: <basename> marker AND skipped
the teardown — leaving a stuck worktree+branch+lease that needed manual recovery. Same SKILL.md
prose, same code path, different LLM interpretations.
The root cause is that the markers and the cleanup are printed/executed by the LLM running each skill, not by the underlying scripts. The SKILL.md prose tells the LLM what to print and do; the LLM “improvises” each invocation. Most of the time the prose is followed; sometimes it isn’t.
The fix: move both responsibilities into
plugin/skills/task-ensure-ready/ensure_ready_mutate.py (the script that already does the file edit
- commit). Make it print the deterministic marker on stdout and, behind an opt-in
--cleanup-on-failflag set by task-work’s invocation, perform the worktree+branch+lease teardown on the fail path. Then task-work’s invocation becomes one shell-out that returns either a clean PASS marker or a clean (already-cleaned-up) FAIL marker, with no LLM judgment between ensure-ready and the next step.
| Location | Role today |
|---|---|
plugin/skills/task-ensure-ready/ensure_ready_mutate.py | Does the file mutation + frontmatter validation + commit. Returns exit 0 on success, non-zero on failure. Does not print any terminal markers — the READY: / NEEDS-DEFINITION: lines are LLM-improvised from the SKILL.md prose’s “Report on stdout” sub-step. |
plugin/skills/task-ensure-ready/SKILL.md | Step 4’s “Report on stdout exactly: ENSURE-READY-OK: |
plugin/skills/task-work/SKILL.md | Step 5a’s NEEDS-DEFINITION-cleanup paragraph (added in PR #167) tells the outer task-work LLM to tear down the worktree+branch+lease when ensure-ready returns the fail marker. The teardown is three shell commands the LLM emits — none of them are script-encapsulated. |
plugin/scripts/sdlc_lease.py | Provides release <ref> for lease teardown; ensure-ready could shell out to this for the cleanup path. |
plugin/skills/task-ensure-ready/test_ensure_ready_mutate.py (if it exists, else sibling test_*.py) | Existing tests cover the file-mutation + commit-on-target-branch paths but not the marker-emission shape (because the script doesn’t emit markers today). |
Proposed
Section titled “Proposed”ensure_ready_mutate.py:
- On PASS (
--mode passsuccess): print the marker on stdout exactly asENSURE-READY-OK: <basename>followed byreadiness_verified_at: <iso>. The shape matches whattask-ensure-ready/SKILL.mdcurrently documents. - On FAIL (
--mode failsuccess): print the marker on stdout exactly asENSURE-READY-NEEDS-DEFINITION: <basename>followed bydefinition_gap: <one-line summary>. Same shape as documented. - Add a new
--cleanup-on-failflag. When set AND the run takes the fail path AND the downshift commit lands successfully on main:- Resolve the worktree path:
<main-repo>/.sdlc/worktrees/<basename>. If it exists, rungit worktree remove --force <worktree-path>. - Resolve the task branch:
task/<basename>. If it exists locally, rungit branch -D <branch>. If a stalefeat/<basename>legacy branch exists (transition window — already squashed in #168 but the code can be defensive), don’t bother —task/<basename>is the only shape going forward. - Release the lease:
plugin/scripts/sdlc_lease.py release refs/sdlc/tasks/<basename>. TolerateREF-NOT-FOUND(treat as already-released, no error). - After cleanup, the script prints an additional marker line:
cleaned-up: worktree=<removed|absent> branch=<deleted|absent> lease=<released|absent>.
- Resolve the worktree path:
- The
--cleanup-on-failflag is opt-in. Standalone/sdlc:task-ensure-readyinvocations (outside task-work) do NOT set it; they want to leave the worktree as-is for the operator. Only task-work’s invocation sets it. - Exit codes stay the same: 0 on success (both PASS and FAIL paths emit their respective markers and exit 0); non-zero on error (validator rejection, lease release library failure, etc.).
task-ensure-ready/SKILL.md:
- Step 4 / Step 5 prose updated: the script now prints the marker. The LLM running the skill does NOT re-print it on top — it relays whatever the script wrote.
- Add a “When called by
/sdlc:task-work” sub-section documenting the--cleanup-on-failflag’s contract.
task-work/SKILL.md:
- Step 5a’s invocation of
/sdlc:task-ensure-readynow passes--cleanup-on-failthrough the--commit-on maininvocation. - The NEEDS-DEFINITION cleanup paragraph (the three-shell-command teardown the LLM was supposed to
do) is replaced with a single sentence: “ensure-ready handled the teardown via
--cleanup-on-fail; just emitTASK-WORK-NEEDS-DEFINITION slug=<basename>and exit.” - task-work’s own terminal verdict on the fail path remains
TASK-WORK-NEEDS-DEFINITION slug=<basename>— that’s the documented contract and stays LLM-emitted at the task-work level (the task-work skill itself is what emits this; if we want to harden this too, that’s a separate task).
Approach
Section titled “Approach”-
Add marker emission to
ensure_ready_mutate.py’s PASS path. After_commit_inreturns 0, print the two-line marker block:ENSURE-READY-OK: <basename>readiness_verified_at: <iso>The
<iso>value comes from thenowvariable already in scope (line ~398). -
Add marker emission to the FAIL path. Same shape:
ENSURE-READY-NEEDS-DEFINITION: <basename>definition_gap: <one-line summary> -
Add the
--cleanup-on-failflag to argparse. Default false. Only meaningful when--mode failis set. -
Implement the cleanup-on-fail branch. After the FAIL marker prints, if the flag is set:
- Resolve
<main-repo>via_resolve_main_checkout(helper already exists). - Compute
<worktree-path> = <main-repo>/.sdlc/worktrees/<basename>. - Run the three teardown commands, tolerating already-clean states (worktree-not-present, branch-not-present, REF-NOT-FOUND for the lease).
- Print
cleaned-up: worktree=<state> branch=<state> lease=<state>.
- Resolve
-
Update tests. Add cases for the marker-emission shape (PASS and FAIL), and for the cleanup-on-fail teardown behaviour. The teardown test creates a worktree+branch+lease via the lease CLI, runs the fail path with
--cleanup-on-fail, asserts all three are gone after. -
Update SKILL.md prose in both
task-ensure-readyandtask-workper Proposed. -
Run quality checks with the baseline-gated invocation. The lint_task_state_commit_origin linter should not flag anything (all changes are implementation diff, no task-state frontmatter on the task branch).
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/skills/task-ensure-ready/ensure_ready_mutate.py | modify | Add marker emission on PASS + FAIL paths; add --cleanup-on-fail flag; implement worktree+branch+lease teardown when flag is set on the fail path. |
plugin/skills/task-ensure-ready/SKILL.md | modify | Step 4 + Step 5 prose: script emits the marker; LLM relays it. Document --cleanup-on-fail contract under a “When called by task-work” sub-section. |
plugin/skills/task-work/SKILL.md | modify | Step 5a’s invocation passes --cleanup-on-fail. NEEDS-DEFINITION-cleanup paragraph replaced with “ensure-ready handled teardown; emit TASK-WORK-NEEDS-DEFINITION slug= |
plugin/skills/task-ensure-ready/test_ensure_ready_mutate.py (or sibling test_*.py if naming convention differs) | modify | Add marker-emission shape tests for PASS + FAIL; add cleanup-on-fail teardown test (worktree + branch + lease all gone after fail run with the flag). |
Acceptance criteria
Section titled “Acceptance criteria”-
AC-1:
plugin/skills/task-ensure-ready/ensure_ready_mutate.py --mode pass --commit-on main ...(against a fixture task) writes the marker on stdout exactly asENSURE-READY-OK: <basename>+readiness_verified_at: <iso>. Verified by a unit test that captures stdout and asserts the literal line shape. -
AC-2: Same script
--mode fail --gap "..." --commit-on main ...writesENSURE-READY-NEEDS-DEFINITION: <basename>+definition_gap: <gap>on stdout. Verified by a unit test. -
AC-3: With
--cleanup-on-failadded to the fail-path invocation, the worktree at.sdlc/worktrees/<basename>, the branchtask/<basename>, and the leaserefs/sdlc/tasks/<basename>are all removed after the script exits 0. Verified by a unit test that sets up all three in a fixture and asserts each is gone post-run. -
AC-4: A live
/sdlc:task-workrun against a task with a known definition gap produces: (a) the downshift commit on main, (b) the worktree+branch+lease all gone, (c) the outer terminal verdictTASK-WORK-NEEDS-DEFINITION slug=<basename>. The bareNEEDS-DEFINITION: <basename>form must not appear in the verdict — the LLM relays the script’s slug-prefixed marker, not the documented prose shape. Validated by a manual dispatch against a task with a stale Today row. -
AC-5:
/Users/sksizer2/.claude/plugins/sdlc/scripts/run_quality_checks.py --config /Users/sksizer2/Developer/dev/sdlc.yaml --diff-against-baseline <origin-main-sha> --baseline-dir /Users/sksizer2/Developer/dev/.sdlc/quality-baselines --linereports zero new drift.
Out of scope
Section titled “Out of scope”- Hardening task-work’s OWN terminal verdict emission. task-work currently has the outer LLM emit
TASK-WORK-DONE pr=#<N>/TASK-WORK-BLOCKED reason=/TASK-WORK-NEEDS-DEFINITION slug=based on SKILL.md prose. The same LLM-nondeterminism risk applies, but it’s a larger refactor (task-work is the orchestrator, not a script that can be shelled out to). If marker drift becomes an observed issue at the task-work level, that’s a follow-up task. - task-define’s marker emission (
TASK-DEFINE-DEFINED:/TASK-DEFINE-NO-CHANGES:/TASK-DEFINE-ALREADY-READY:). Same pattern; deferred to a follow-up. - spawn-task-pr’s marker emission (
SPAWN-TASK-PR-DONE pr=...). Same pattern; deferred. - Refactoring the lease release call into a Python import rather than a shell-out. Keeping the shell-out boundary so the script remains independently runnable from a shell session for ops/debugging.
Dependencies
Section titled “Dependencies”- none — sits on top of the post-#167 + post-#165 + post-#168 codebase.
Discovery context
Section titled “Discovery context”Surfaced 2026-05-28 during the first batch of two parallel post-#167 task-work dispatches. Both
tasks were open/ready with stale Today rows (drift the validator catches). Both sub-agents took
the FAIL path inside ensure-ready and produced a downshift commit on main correctly. One sub-agent
(plugin-scripts-self-discover-project-root) then followed the SKILL.md prose perfectly: emitted
TASK-WORK-NEEDS-DEFINITION slug=... as task-work’s verdict AND tore down its
worktree+branch+lease. The other (python-runtime-doc-additions) emitted the bare
NEEDS-DEFINITION: <basename> form (ensure-ready’s intermediate marker, leaked) AND skipped the
teardown — same SKILL.md, same code path, different LLM-prose interpretation. The split-outcome
pattern names the abstraction failure: LLM-improvised markers + LLM-improvised cleanup is fragile.
The fix moves both responsibilities into the deterministic script tier.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-28. PR: pending.
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
plugin/skills/task-ensure-ready/tests/run_evals.pycasepass-marker-shapeasserts the literal two-lineENSURE-READY-OK: <basename>+readiness_verified_at: <iso>shape captured from stdout against a fixture repo on--commit-on main. - AC-2: auto — same suite’s
fail-marker-shapecase assertsENSURE-READY-NEEDS-DEFINITION: <basename>+definition_gap: <gap>from stdout on the--mode fail --commit-on mainpath. - AC-3: auto —
cleanup-on-fail-tears-down-worktree-and-branchbuilds a fixture repo withgit worktree add+task/<basename>branch and asserts both are gone after--cleanup-on-fail; the lease verb is exercised via the deterministic “nosdlc.yaml⇒ lease=absent” path (hermetic, no external authority needed). Companion casecleanup-on-fail-tolerates-absent-targetscovers the idempotent “everything already gone” run. - AC-4: deferred-user — a live
/sdlc:task-workdispatch against a task with a known definition gap requires the merged PR to propagate to${CLAUDE_PLUGIN_ROOT}first (the runtime SKILL.md the operator’s harness loads is the global plugin path, not the worktree). Please verify after merge by running/sdlc:task-work <slug>against aplanning/proposedtask with a stale Today row and confirming (a) the downshift commit landed on main, (b) the worktree+branch+lease are all gone, (c) the outer verdict isTASK-WORK-NEEDS-DEFINITION slug=<basename>(NOT the bareNEEDS-DEFINITION: <basename>form). - AC-5: auto —
run_quality_checks.py --diff-against-baseline 079f7957d... --linereportedOK 14/14 (baseline-gated; pre-existing findings ignored)— zero new drift.
What worked
Section titled “What worked”- The existing
ensure_ready_mutate.pyalready had a clean separation between argparse, mutation helpers, and the commit pipeline, so the marker emission slotted in with one helper per shape and a small main() change. - The existing eval suite’s
_build_marker_fixture_repopattern (init bare repo + seed task fixture) was easy to reuse for the 7 new behavioural tests. run_quality_checks.py --diff-against-baselinemade it cheap to confirm zero new drift even though the baseline already had ~243 pre-existing findings.
Friction and automation gaps
Section titled “Friction and automation gaps”- A pre-existing
grepalias in the user’s shell (--no-headingnot recognized) interfered with one diagnostic command; mitigated by switching tocommand grep. The SKILL.md already calls this out in Step 2’s relevance check, but the same hazard hit a casual diff confirmation here — task-work or the validator helpers could shell out viacommand grepconsistently in any wrapped diagnostics they emit. - The runtime SKILL.md for
/sdlc:task-ensure-readyloaded by the Skill harness is the pre-#165/#167 version (no--commit-on main, no slug-prefixed markers), so when this skill invoked it via the Skill tool the operator-facing prose disagreed with the worktree’s source. The parent task-work prose anticipates this (“the runtime version of each is loaded from${CLAUDE_PLUGIN_ROOT}, not the worktree”), but only by manual hook output — the SKILL.md surface could carry an explicit “if these instructions conflict with the worktree’s source, the worktree wins for this task” header to make the precedence unambiguous. - The placeholder scanner flagged this task’s
<basename>/<iso>mentions as disqualifiers because they live in markdown table cells and prose, even though they’re meta-discussion of marker shapes. The scanner already skips fenced code blocks; it could also skip rows that are clearly describing marker/template shapes (e.g. rows whose left column is a path ending in.pyor.mdand whose right column is descriptive prose mentioning the shape), or — narrower — allow<basename>,<iso>,<one-line summary>,<sha>,<...>as known marker-shape tokens when the surrounding prose contains “marker” / “shape” / “stdout” / “emits” within ±2 lines.