T-QC31-add-sdlc-lease-cli-commands
Status: closed/done · Impact: high · Complexity: medium
Expose every protocol primitive from the lease library behind a deterministic sdlc lease … CLI
surface so operators (and slice 2’s lease-aware skills) can exercise the protocol without writing
Python. After this task lands, sdlc lease task claim <id> and friends are real commands with
documented exit codes and stable stdout markers; this is what makes slice 2 implementable and what
makes the protocol manually inspectable during early operation.
sdlc already has a CLI surface (commands like /sdlc:task-work are Claude Code skills; CLI-shaped
invocations live in plugin/scripts/ or are inlined inside skill prose). There is no sdlc lease
subcommand. Once T-S0PK-add-lease-protocol-library-and-schemas lands, the library is callable
from Python but has no CLI binding — the only way to exercise the protocol manually would be to drop
into a Python REPL.
The repo’s existing CLI conventions for new commands need to be confirmed during implementation —
sample patterns live in plugin/scripts/ (deterministic one-shot scripts) and in the skill-invoked
argparse parsers (e.g., count_inflight_tasks.py). This task picks the convention closest to those
scripts so the new commands feel consistent.
Proposed
Section titled “Proposed”Seven subcommands under sdlc lease …, each a thin argparse-driven wrapper around the corresponding
library call:
| Command | Library call | Stdout marker(s) |
|---|---|---|
sdlc lease task claim <task-id> [--owner-kind <kind>] | cas_create for a fresh task lifecycle lease | CLAIMED task=<id> lease_id=<uuid> lease_token=<uuid> |
sdlc lease task transition <task-id> --phase <phase> [--pr-number N] | cas_replace updating lease.json | TRANSITIONED task=<id> phase=<phase> lease_id=<uuid> |
sdlc lease op claim <operation> <key> [--ttl <seconds>] | cas_create for an operation lease | CLAIMED op=<op> key=<key> lease_id=<uuid> |
sdlc lease heartbeat <ref> | cas_replace rewriting expires_at | HEARTBEAT ref=<ref> expires_at=<rfc3339> |
sdlc lease release <ref> | cas_delete or archive-then-delete depending on flag | RELEASED ref=<ref> |
sdlc lease inspect <ref> | fetch_ref + pretty-print of the resolved lease.json | (table or JSON; --json flag toggles) |
sdlc lease list [--prefix refs/sdlc/...] [--json] | fetch_namespace + iteration | (table or JSON; one row per ref) |
Every command also emits structured error markers on failure:
CAS-FAILED ref=<ref> expected=<sha> actual=<sha>(exit code 2)LEASE-EXPIRED task=<id> expires_at=<rfc3339>(exit code 3, for transitions that require a still-active lease)LEASE-CONFLICT ...(exit code 4)REF-NOT-FOUND ref=<ref>(exit code 5)SCHEMA-ERROR ...(exit code 6)- Generic errors → exit code 1, stderr explanation.
Authority configuration: read from sdlc.yaml at the project root; fall back to
--authority <name-or-path> override per-invocation. If neither is set, exit with a clear error
pointing at the setup docs.
Entry-point shape: plugin/scripts/sdlc_lease.py exposing a main() that argparse dispatches. The
script follows the existing plugin/scripts/ pattern documented in
plugin/conventions/python-runtime.md (introduced by
T-S0PK-add-lease-protocol-library-and-schemas):
#!/usr/bin/env -S uv run --quiet --scriptshebang.- Inline
# /// script ... # ///dependency block listing only what this script needs at import time (e.g.,pyyamlfor readingsdlc.yaml); library deps that bubble up via the lease library are declared in lease’s own scope and pulled transitively when uv resolves the script. - 2-line
sys.path.insert(...)bootstrap pointing atplugin/lib/sofrom lease import primitives, schemasresolves.
No console-script entry in any pyproject.toml (the repo isn’t packaged); invocation is direct via
the script path: plugin/scripts/sdlc_lease.py task claim <id>. Slice 2 callers (skills) call the
script with explicit paths.
Approach
Section titled “Approach”- Survey existing CLI conventions. Read
plugin/scripts/count_inflight_tasks.py,plugin/scripts/worktree_scope_guard.py, and anybosun/-side CLI patterns. Document the convention the newsdlc leasecommands will match (argparse vs click, exit-code conventions, stdout marker formats,--jsonflag patterns). - Scaffold the dispatcher. Create
plugin/scripts/sdlc_lease.pywith amain()that parsessdlc lease <subcommand> ...and dispatches to per-subcommand handlers. Top-level--authorityand--jsonflags inherited by all subcommands. - Implement each subcommand. One file per subcommand under
plugin/scripts/lease/(or all insdlc_lease.pyif it stays small enough — decision deferred until step 2 is done and we see the size). Each handler:- Validates CLI args.
- Resolves the authority (CLI >
sdlc.yaml> error). - Calls into the lease library.
- Catches library exceptions and maps to the structured error markers + exit codes documented above.
- Emits the success marker on stdout.
- Implement the
inspectcommand’s pretty-print. When invoked without--json, renderlease.jsonas a two-column key/value table with phase / owner / lease_id / expires_at / PR number prominently. With--json, emit the raw payload byte-for-byte fromfetch_ref+ JSON-validate. - Implement
list. Walksfetch_namespaceresults and renders one row per lease with key fields (task_id / phase / owner / expires_at). Default prefix isrefs/sdlc/tasks/;--prefix refs/sdlc/ops/etc. as override. - Verify the bootstrap works under uv-inline-script. Confirm that the 2-line
sys.path.insert(...)frompython-runtime.mdresolvesfrom lease import ...when the script is invoked asplugin/scripts/sdlc_lease.py task claim <id>. No additionalpyproject.toml/ console-script wiring required. - Write integration tests under
plugin/scripts/lease/tests/(or alongsidesdlc_lease.py). Each subcommand gets a happy-path test and a failure-mode test. Tests use the lease library’s local-bare-repo fixture (introduced in the foundation task) so they’re network-free. - Manual smoke test. From the repo root, exercise the full claim → heartbeat → transition → inspect → release flow against a local bare repo. Capture the output as documentation.
- Document the surface. Add
plugin/scripts/lease/README.md(or a section inplugin/lib/lease/README.md) with every command’s signature, exit codes, stdout markers, and a worked example. Cross-linkpython-runtime.mdfor the invocation pattern.
Files to touch
Section titled “Files to touch”| Location | Kind | Change |
|---|---|---|
plugin/scripts/sdlc_lease.py | new | Top-level argparse dispatcher for sdlc lease …; uv-inline-script shebang + 2-line sys.path.insert(...) bootstrap so from lease import ... resolves |
plugin/scripts/lease/ | new | Directory for per-subcommand handler modules if sdlc_lease.py outgrows itself (decision deferred to impl) |
plugin/scripts/lease/tests/test_subcommands.py | new | Integration tests covering each subcommand’s happy path + every documented exit code (1–6), invoked via subprocess.run([script_path, ...]) to exercise the real entry point |
plugin/scripts/lease/README.md | new | CLI reference: every command signature, exit codes, stdout markers, worked smoke-test example; cross-links python-runtime.md for invocation pattern |
plugin/lib/lease/README.md | modify | Add a one-line forward-pointer “for CLI usage, see plugin/scripts/lease/README.md” |
plugin/schemas/sdlc-yaml.schema.json | modify | Register the new lease_authority (or similar) config key if not already present; coordinate with the existing schema’s shape |
Explicit non-edit:
| Location | Kind | Change |
|---|---|---|
pyproject.toml | new | (not created — explicit decision; python-runtime.md records the “no packaging yet” stance) |
Acceptance criteria
Section titled “Acceptance criteria”- AC-1:
sdlc lease task claim <id>(or the equivalent invocation depending on entry-point choice) against a configured local bare-repo authority produces a CAS-CREATE onrefs/sdlc/tasks/<id>, emitsCLAIMED task=<id> lease_id=<uuid> lease_token=<uuid>on stdout, and exits 0. Verified by both an integration test and a manual smoke run. - AC-2: Calling
sdlc lease task claim <id>against a ref that already exists exits with code 2 and emitsCAS-FAILED ref=refs/sdlc/tasks/<id> expected=<empty> actual=<sha>on stderr. The local repo state is unchanged. - AC-3:
sdlc lease task transition <id> --phase workingcorrectly fetches the current lease ref, rebuilds the lease payload at the new phase, and CAS-REPLACE’s. Lease token rotates only when ownership changes (i.e., on phases that imply a new owner) — verified by an explicit assertion. - AC-4:
sdlc lease heartbeat <ref>updatesexpires_attonow + TTLand CAS-REPLACE’s. Calling it more frequently than the library-enforced floor (TTL/4) errors out with a deterministic message; the floor enforcement lives in the library and the CLI just surfaces it. - AC-5:
sdlc lease inspect <ref>against an existing lease prints a table with phase, owner, lease_id, expires_at, and PR number visible. With--json, the raw payload is byte-identical to whatgit show <ref>:lease.jsonwould produce. - AC-6:
sdlc lease listproduces one row per active task lease by default.--prefix refs/sdlc/ops/lists operation leases.--jsonproduces a JSON array of objects; the same data is selectable withjq. - AC-7:
sdlc lease release <ref>against an expired or completed lease moves the ref torefs/sdlc/archive/...and deletes the active ref. The archive ref’s commit history matches the active ref’s pre-release history. - AC-8: Every documented exit code (1–6) is reachable by a corresponding failure-mode test.
Tests live in
plugin/scripts/lease/tests/and run green underpytest. - AC-9:
plugin/scripts/lease/README.md(or equivalent) documents every command’s signature, exit codes, and stdout markers, and the worked example from the smoke test copy-pastes runnable into a shell. - AC-10: No command shells out to
git pushagainst the lease namespace directly — every protocol operation goes through the library, per the ADR’s “no caller writes rawgit push” rule. Verified bygrep -rn "git push" plugin/scripts/sdlc_lease.py plugin/scripts/lease/returning zero matches. - AC-11:
plugin/scripts/sdlc_lease.pyruns end-to-end as./plugin/scripts/sdlc_lease.py task claim <id>directly from the repo root with no setup beyonduvbeing on PATH. Asserted by the manual smoke test in step 8, plus an integration test that invokes the script viasubprocess.run([str(script_path), ...])and asserts the expected stdout marker.
Out of scope
Section titled “Out of scope”- Skill wiring —
/sdlc:orchestrate,/sdlc:task-work,/sdlc:task-close-out,/sdlc:pr-respondlearning to call these commands. That’s slice 2. control-plane.jsonreading beyond a “did this command’s authority have one?” check on startup. Full control-plane compat-checking lives in slice 2 alongside orchestrate.- Background heartbeat thread management — the CLI exposes
sdlc lease heartbeatas a single shot; a daemonized “keep heartbeating until I tell you to stop” loop is implemented by callers (slice 2’s task-work / non-LLM workers). sdlc lease migrate(the cutover migration tooling) — that’s slice 3.- Authority-side setup commands (
sdlc lease authority initor similar) — out of scope here; manual setup per the ADR’s Local backend setup is acceptable for slice 1.
Dependencies
Section titled “Dependencies”- T-S0PK-add-lease-protocol-library-and-schemas — must land first. This task’s commands are bindings; the library provides the underlying behavior.
Discovery context
Section titled “Discovery context”This task realizes Rollout Plan item 3 from the ADR’s
protocol.md. It is split out
from the library task because the CLI surface has its own design surface (exit codes, stdout marker
conventions, --json flag patterns, authority-config resolution order) that benefits from being
designed in one focused PR rather than mixed in with library-implementation concerns.
Post-mortem
Section titled “Post-mortem”Captured by /sdlc:task-work on 2026-05-23. PR: pending. Stacked on PR #113 (lease library).
Acceptance criteria coverage
Section titled “Acceptance criteria coverage”- AC-1: auto —
test_task_claim_emits_marker_and_creates_refplus an agent-manual smoke run confirmed./plugin/scripts/sdlc_lease.py task claim <id>produces theCLAIMED task=<id> lease_id=<uuid> lease_token=<uuid>marker and exits 0. - AC-2: auto —
test_task_claim_twice_yields_cas_failed_exit_2asserts exit 2 +CAS-FAILEDstderr marker on the duplicate-create path. Repo state unchanged. - AC-3: auto —
test_task_transition_rotates_token_to_working+…_keeps_token_on_awaiting_reviewcover the directional rotation rule (claimed/working = rotate; other phases = preserve). Token-rotation heuristic is documented as approximate vs the ADR’s full matrix; deferred to slice 2. - AC-4: auto — three heartbeat tests: happy-path
expires_atrewrite, floor enforcement at TTL/4, and the runaway-churn rejection. - AC-5: auto —
test_inspect_table_shows_required_columns+…_byte_identical_to_git_show(the JSON path matchesgit show <ref>:lease.jsonbyte-for-byte). - AC-6: auto — three list tests covering default prefix,
--prefix refs/sdlc/ops/, and--jsonarray shape. - AC-7: auto —
test_release_moves_ref_to_archive_and_deletes_activeasserts the rename-then-delete sequence and that archive ref history matches pre-release. - AC-8: auto — 5 dedicated
test_exit_N_*tests cover exits 1, 3, 4, 5, 6; exit 2 is covered transitively by AC-2’s test. - AC-9: agent-manual —
plugin/scripts/lease_cli/README.mddocuments every command’s signature, exit codes, stdout markers; worked example was copy-pasted from a smoke-test transcript and is runnable as-is. - AC-10: auto —
test_no_git_push_shells_in_cligreps only the top-level.pyfiles in the CLI source directory (excludingtests/and__pycache__/) and asserts zero hits. - AC-11: auto — every test in the suite invokes the script as a subprocess
(
subprocess.run([str(script_path), …])); the manual smoke run additionally confirmed direct invocation works end-to-end with onlyuvon PATH.
What worked
Section titled “What worked”- Stacking on the lease-library branch worked cleanly — no conflicts, library imports resolved
immediately via the convention’s
pytest.inipythonpath. - One file per subcommand under
plugin/scripts/lease_cli/kept each handler tightly focused; the dispatcher (sdlc_lease.py) just routes argparse to the handler module’smain(args, ctx). - The 5-commit decomposition (scaffold → 3 subcommands → 4 subcommands → tests → docs) was reviewable in isolation; no monster commits.
- Reusing the lease library’s
conftest.pyfixtures (imported via the shared pytest pythonpath) meant zero fixture duplication.
Friction and automation gaps
Section titled “Friction and automation gaps”- Name collision:
lease(library package) vslease/(CLI subdir). The task spec called forplugin/scripts/lease/for handler modules, butplugin/lib/lease/is already onsys.pathand would shadow it (or vice versa). Renamed the CLI subpackage toplugin/scripts/lease_cli/and documented the rationale in the package docstring. The spec’s files-to-touch table still references the old name; the deviation is recorded here. Suggests: task specs that propose new module names should grep for collisions during planning, ORpython-runtime.mdshould add a “avoid naming a script package the same as a library package” note. → T-1JAO-python-runtime-doc-warns-script-lib-name-collision - Heartbeat floor needs
ttl_secondsin payload for full correctness. The CLI doesn’t know a lease’s original TTL — only the new TTL being requested. Implemented “prior heartbeat at ≈ expires_at − new_ttl” as a heuristic; this means immediately after a claim/transition, any heartbeat hits the floor at default TTL (correct behavior — the lease was just set to now+TTL so a fresh heartbeat is by definition runaway churn). A clean fix would addttl_secondsto the lease payload schema. Out of slice 1 scope; documented in the CLI README’s heartbeat section. → T-954I-lease-payload-adds-ttl-seconds - Token-rotation policy is approximate. Implemented the directional rule “transitions to
claimedorworkingrotate; other phases preserve” which matches AC-3’s stated intent. The ADR’s full owner-rotation matrix is richer (e.g., explicit rotation on cross-host successor takeover). The CLI’s rule is correct for single-host operation and gets the slice-1 cases right; slice 2’s task-work integration will need the fuller policy. Documented as a slice-1 limitation in the README. → T-1VF3-lease-token-rotation-full-policy - AC-10 grep test footgun. First cut greped the CLI source directory recursively and matched the
test file’s docstring containing “git push” plus a compiled
__pycache__/*.pyc. Fixed by greping only top-level.pyfiles in the CLI dir (excludingtests/and__pycache__/). Suggests: grep-based ACs should default to skipping test files and bytecode, OR a project-wide grep helper that already handles those exclusions would be safer than each AC re-deriving them. → T-HQ56-safe-grep-helper-for-ac-shell - Quality runner is cwd-sensitive. First run reported
audit_entities.pyfailing even though direct invocation passed (the runner started from a non-worktree cwd). Running from the worktree root made it pass 12/12. Not a code defect — but an invocation gotcha that cost a tick of investigation. Suggests:run_quality_checks.pycould log its working directory at start, or each verb could be invoked with an explicit--project-rootif the verb supports it. → T-NUSP-run-quality-checks-logs-cwd-and-forwards-project-root - Test fixture for
git showbyte-equivalence flake.test_inspect_json_is_byte_identical_to_git_showinitially failed because the CLI runs against a worker repo’s bare-remote authority and the worker repo doesn’t auto-track the lease ref. Fixed by explicitgit fetchin the test beforegit show. Suggests: the lease library’s local-bare-repo fixture should expose a “fetch all sdlc refs into worker” helper so tests don’t reinvent this. → T-A5BL-lease-fixture-exposes-fetch-helper
Spawned follow-up tasks
Section titled “Spawned follow-up tasks”- T-1JAO-python-runtime-doc-warns-script-lib-name-collision — python-runtime.md gains a “avoid naming a script package the same as a library package” subsection (created)
- T-954I-lease-payload-adds-ttl-seconds — add
ttl_secondsto the lease payload schema so the heartbeat floor is precise (created) - T-1VF3-lease-token-rotation-full-policy — move token-rotation policy out of the CLI and into the library, covering the ADR’s full owner-rotation matrix (created)
- T-HQ56-safe-grep-helper-for-ac-shell — add a shared grep helper with safe defaults (excludes tests + bytecode) for negative-presence ACs (created)
- T-NUSP-run-quality-checks-logs-cwd-and-forwards-project-root — runner logs starting cwd and
forwards
--project-rootto capable verbs (created) - T-A5BL-lease-fixture-exposes-fetch-helper — local-bare-repo fixture exposes a
fetch_sdlc_refs(worker)helper (created)