Skip to content

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.

Seven subcommands under sdlc lease …, each a thin argparse-driven wrapper around the corresponding library call:

CommandLibrary callStdout marker(s)
sdlc lease task claim <task-id> [--owner-kind <kind>]cas_create for a fresh task lifecycle leaseCLAIMED task=<id> lease_id=<uuid> lease_token=<uuid>
sdlc lease task transition <task-id> --phase <phase> [--pr-number N]cas_replace updating lease.jsonTRANSITIONED task=<id> phase=<phase> lease_id=<uuid>
sdlc lease op claim <operation> <key> [--ttl <seconds>]cas_create for an operation leaseCLAIMED op=<op> key=<key> lease_id=<uuid>
sdlc lease heartbeat <ref>cas_replace rewriting expires_atHEARTBEAT ref=<ref> expires_at=<rfc3339>
sdlc lease release <ref>cas_delete or archive-then-delete depending on flagRELEASED 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 --script shebang.
  • Inline # /// script ... # /// dependency block listing only what this script needs at import time (e.g., pyyaml for reading sdlc.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 at plugin/lib/ so from lease import primitives, schemas resolves.

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.

  1. Survey existing CLI conventions. Read plugin/scripts/count_inflight_tasks.py, plugin/scripts/worktree_scope_guard.py, and any bosun/-side CLI patterns. Document the convention the new sdlc lease commands will match (argparse vs click, exit-code conventions, stdout marker formats, --json flag patterns).
  2. Scaffold the dispatcher. Create plugin/scripts/sdlc_lease.py with a main() that parses sdlc lease <subcommand> ... and dispatches to per-subcommand handlers. Top-level --authority and --json flags inherited by all subcommands.
  3. Implement each subcommand. One file per subcommand under plugin/scripts/lease/ (or all in sdlc_lease.py if 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.
  4. Implement the inspect command’s pretty-print. When invoked without --json, render lease.json as 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 from fetch_ref + JSON-validate.
  5. Implement list. Walks fetch_namespace results and renders one row per lease with key fields (task_id / phase / owner / expires_at). Default prefix is refs/sdlc/tasks/; --prefix refs/sdlc/ops/ etc. as override.
  6. Verify the bootstrap works under uv-inline-script. Confirm that the 2-line sys.path.insert(...) from python-runtime.md resolves from lease import ... when the script is invoked as plugin/scripts/sdlc_lease.py task claim <id>. No additional pyproject.toml / console-script wiring required.
  7. Write integration tests under plugin/scripts/lease/tests/ (or alongside sdlc_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.
  8. 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.
  9. Document the surface. Add plugin/scripts/lease/README.md (or a section in plugin/lib/lease/README.md) with every command’s signature, exit codes, stdout markers, and a worked example. Cross-link python-runtime.md for the invocation pattern.
LocationKindChange
plugin/scripts/sdlc_lease.pynewTop-level argparse dispatcher for sdlc lease …; uv-inline-script shebang + 2-line sys.path.insert(...) bootstrap so from lease import ... resolves
plugin/scripts/lease/newDirectory for per-subcommand handler modules if sdlc_lease.py outgrows itself (decision deferred to impl)
plugin/scripts/lease/tests/test_subcommands.pynewIntegration 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.mdnewCLI reference: every command signature, exit codes, stdout markers, worked smoke-test example; cross-links python-runtime.md for invocation pattern
plugin/lib/lease/README.mdmodifyAdd a one-line forward-pointer “for CLI usage, see plugin/scripts/lease/README.md
plugin/schemas/sdlc-yaml.schema.jsonmodifyRegister the new lease_authority (or similar) config key if not already present; coordinate with the existing schema’s shape

Explicit non-edit:

LocationKindChange
pyproject.tomlnew(not created — explicit decision; python-runtime.md records the “no packaging yet” stance)
  • 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 on refs/sdlc/tasks/<id>, emits CLAIMED 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 emits CAS-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 working correctly 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> updates expires_at to now + TTL and 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 what git show <ref>:lease.json would produce.
  • AC-6: sdlc lease list produces one row per active task lease by default. --prefix refs/sdlc/ops/ lists operation leases. --json produces a JSON array of objects; the same data is selectable with jq.
  • AC-7: sdlc lease release <ref> against an expired or completed lease moves the ref to refs/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 under pytest.
  • 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 push against the lease namespace directly — every protocol operation goes through the library, per the ADR’s “no caller writes raw git push” rule. Verified by grep -rn "git push" plugin/scripts/sdlc_lease.py plugin/scripts/lease/ returning zero matches.
  • AC-11: plugin/scripts/sdlc_lease.py runs end-to-end as ./plugin/scripts/sdlc_lease.py task claim <id> directly from the repo root with no setup beyond uv being on PATH. Asserted by the manual smoke test in step 8, plus an integration test that invokes the script via subprocess.run([str(script_path), ...]) and asserts the expected stdout marker.
  • Skill wiring/sdlc:orchestrate, /sdlc:task-work, /sdlc:task-close-out, /sdlc:pr-respond learning to call these commands. That’s slice 2.
  • control-plane.json reading 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 heartbeat as 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 init or similar) — out of scope here; manual setup per the ADR’s Local backend setup is acceptable for slice 1.

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.

Captured by /sdlc:task-work on 2026-05-23. PR: pending. Stacked on PR #113 (lease library).

  • AC-1: auto — test_task_claim_emits_marker_and_creates_ref plus an agent-manual smoke run confirmed ./plugin/scripts/sdlc_lease.py task claim <id> produces the CLAIMED task=<id> lease_id=<uuid> lease_token=<uuid> marker and exits 0.
  • AC-2: auto — test_task_claim_twice_yields_cas_failed_exit_2 asserts exit 2 + CAS-FAILED stderr marker on the duplicate-create path. Repo state unchanged.
  • AC-3: auto — test_task_transition_rotates_token_to_working + …_keeps_token_on_awaiting_review cover 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_at rewrite, 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 matches git show <ref>:lease.json byte-for-byte).
  • AC-6: auto — three list tests covering default prefix, --prefix refs/sdlc/ops/, and --json array shape.
  • AC-7: auto — test_release_moves_ref_to_archive_and_deletes_active asserts 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.md documents 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_cli greps only the top-level .py files in the CLI source directory (excluding tests/ 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 only uv on PATH.
  • Stacking on the lease-library branch worked cleanly — no conflicts, library imports resolved immediately via the convention’s pytest.ini pythonpath.
  • 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’s main(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.py fixtures (imported via the shared pytest pythonpath) meant zero fixture duplication.
  • Name collision: lease (library package) vs lease/ (CLI subdir). The task spec called for plugin/scripts/lease/ for handler modules, but plugin/lib/lease/ is already on sys.path and would shadow it (or vice versa). Renamed the CLI subpackage to plugin/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, OR python-runtime.md should 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_seconds in 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 add ttl_seconds to 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 claimed or working rotate; 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 .py files in the CLI dir (excluding tests/ 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.py failing 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.py could log its working directory at start, or each verb could be invoked with an explicit --project-root if the verb supports it. → T-NUSP-run-quality-checks-logs-cwd-and-forwards-project-root
  • Test fixture for git show byte-equivalence flake. test_inspect_json_is_byte_identical_to_git_show initially 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 explicit git fetch in the test before git 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

← Back to Tasks