From f18e4d3b332de928ca0d36c70d94b185239410fd Mon Sep 17 00:00:00 2001 From: Joshua Oliphant Date: Wed, 24 Jun 2026 20:34:42 -0700 Subject: [PATCH] =?UTF-8?q?feat(stick-shift):=20handoff-grade=20.sdlc=20?= =?UTF-8?q?=E2=80=94=20commit/cycle=20stamps=20+=20increment=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a 3-experiment cold-agent handoff study on a stick-shift session: A. Record↔git disconnect: entries had no pointer to the code they describe. - transition/decide/init now stamp the short commit SHA + increment cycle on each history/decision entry; init captures branch. status/journal surface them. - The SHA is a foreign key: git log .. recovers a phase's work. B. No 'next increment' lifecycle: DONE was a terminal sink and init is resume-only (it silently dropped a new --feature on an existing session), so increment 2 was only reachable via an off-graph DONE->SPEC nudge that left feature/request stale and records undelimited. - New 'increment' command: archives the finished increment, retargets feature/request, bumps the cycle, resets to INIT so /spec is on-graph. - /spec command doc + README updated with the increment-aware resume path. 17 tests pass (6 new, TDD), ruff clean. v0.4.0 -> v0.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude-plugin/marketplace.json | 2 +- .../stick-shift/.claude-plugin/plugin.json | 4 +- plugins/stick-shift/README.md | 18 ++- plugins/stick-shift/commands/spec.md | 7 + plugins/stick-shift/scripts/session_state.py | 128 +++++++++++++++++- .../stick-shift/scripts/test_session_state.py | 79 +++++++++++ 6 files changed, 227 insertions(+), 11 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6628e29..dca6a51 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -164,7 +164,7 @@ "name": "stick-shift", "source": "./plugins/stick-shift", "description": "Manually-driven (\"disassembled\") SDLC: the same .sdlc/ session format as autonomous-sdlc, but you drive each phase by hand via slash commands — /spec → /plan → /build (TDD) → /verify → /journal. Built for legible, narratable live demos.", - "version": "0.4.0", + "version": "0.5.0", "author": { "name": "Joshua Oliphant" }, diff --git a/plugins/stick-shift/.claude-plugin/plugin.json b/plugins/stick-shift/.claude-plugin/plugin.json index fd3b58f..5704744 100644 --- a/plugins/stick-shift/.claude-plugin/plugin.json +++ b/plugins/stick-shift/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "stick-shift", - "version": "0.4.0", - "description": "Manually-driven (\"disassembled\") SDLC: the same .sdlc/ session format as autonomous-sdlc, but you drive each phase by hand via slash commands — /spec → /plan → /build (TDD) → /verify → /journal. Built for legible, narratable live demos.", + "version": "0.5.0", + "description": "Manually-driven (\"disassembled\") SDLC: the same .sdlc/ session format as autonomous-sdlc, but you drive each phase by hand via slash commands \u2014 /spec \u2192 /plan \u2192 /build (TDD) \u2192 /verify \u2192 /journal. Built for legible, narratable live demos.", "author": { "name": "Joshua Oliphant", "email": "joshuaoliphant@gmail.com" diff --git a/plugins/stick-shift/README.md b/plugins/stick-shift/README.md index 0c20d1f..b2ed794 100644 --- a/plugins/stick-shift/README.md +++ b/plugins/stick-shift/README.md @@ -47,7 +47,23 @@ and run `/sdlc`. You can also seed `/spec` from an upstream plan — `/spec docs/.../my-plan.md` ingests it, normalizes its criteria into the testable contract, and records a `Source:` pointer. +## Next increment (after DONE) + +Work arrives in waves, so `DONE` is not the end of the road. When a finished +(`state: DONE`) stick-shift session needs the *next* increment, `/spec` runs +`session_state.py increment --feature --request ""`. It archives the finished +increment into `increments[]`, retargets `feature`/`request` (so `status` never lies), +bumps the `cycle` counter so the durable records stay grouped per increment, and resets +to `INIT` — so the new `/spec` transitions on-graph with no nudge. This is the +first-class path; never force an off-graph `DONE → SPEC` or hand-edit `state.json`. + ## State CLI `python3 scripts/session_state.py --help` documents the manual operations: `init`, -`state`, `status`, `transition`, `decide`, `note-progress`, `task`, `journal`, `takeover`. +`state`, `status`, `transition`, `decide`, `increment`, `note-progress`, `task`, +`journal`, `takeover`. + +Every `transition`, `decide`, and `increment` stamps the current short commit SHA and +the increment `cycle` onto the record entry — a foreign key joining each entry to the +code it describes (`git log ..` recovers a phase's work). `status` +and `journal` surface the branch, cycle, and per-entry SHA. diff --git a/plugins/stick-shift/commands/spec.md b/plugins/stick-shift/commands/spec.md index 06e3d5b..bde9b6f 100644 --- a/plugins/stick-shift/commands/spec.md +++ b/plugins/stick-shift/commands/spec.md @@ -27,6 +27,13 @@ Do exactly one phase, then stop and hand control back. autonomous-sdlc), run `python3 $STATE takeover` to stand its loop down so it won't fight your manual driving; narrate it ("found an autonomous session — taking the wheel, standing down the autopilot"). + - If `RESUME` shows a **finished stick-shift session** (`state.json` state `DONE`, + no autonomous `driver`) and `$ARGUMENTS` is a *new* piece of work, you are starting + the **next increment**, not resuming the old one. Run + `python3 $STATE increment --feature {new-slug} --request ""` — it archives the + finished increment, retargets feature/request, bumps the cycle so the records stay + grouped, and resets to INIT so this `/spec` transitions on-graph (no nudge). Do NOT + hand-edit `state.json` or force an off-graph `DONE → SPEC`. - Create a branch if not on one: `git checkout -b stickshift/{slug}`. 2. **Capture the acceptance criteria** into `specs/{slug}-spec.md`, numbered AC-1, AC-2, … in Given/When/Then form. Branch on the input type: diff --git a/plugins/stick-shift/scripts/session_state.py b/plugins/stick-shift/scripts/session_state.py index 3ac1e0f..09db588 100644 --- a/plugins/stick-shift/scripts/session_state.py +++ b/plugins/stick-shift/scripts/session_state.py @@ -22,6 +22,7 @@ import argparse import json +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -49,6 +50,34 @@ def now(): return datetime.now(timezone.utc).isoformat(timespec="seconds") +def _git(*args): + """Best-effort git query. Returns stripped stdout, or None outside a repo.""" + try: + out = subprocess.run(["git", *args], capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError): + return None + return (out.stdout.strip() or None) if out.returncode == 0 else None + + +def git_head(): + """Short SHA of HEAD — the foreign key joining a record entry to its code. + + Captured at write time, so it anchors the entry to the repo state it was + recorded against. Stick-shift commits a phase *after* transitioning, so an + entry's commit is the baseline the phase started from; the diff to the next + entry's commit is that phase's work. None outside a git repo. + """ + return _git("rev-parse", "--short", "HEAD") + + +def git_branch(): + """Current branch name, or the short SHA on a detached HEAD. None if no repo.""" + branch = _git("rev-parse", "--abbrev-ref", "HEAD") + if branch == "HEAD": + return git_head() + return branch + + def load(): if not STATE_FILE.exists(): sys.exit("No .sdlc/state.json — run `session_state.py init` first.") @@ -78,8 +107,19 @@ def cmd_init(args): "feature": args.feature, "request": args.request, "state": "INIT", + "cycle": 1, + "branch": git_branch(), "in_flight": [], - "history": [{"at": now(), "to": "INIT", "reason": "initialized"}], + "increments": [], + "history": [ + { + "at": now(), + "to": "INIT", + "reason": "initialized", + "commit": git_head(), + "cycle": 1, + } + ], "started": now(), } save(state) @@ -97,16 +137,23 @@ def cmd_state(_args): def cmd_status(_args): s = load() - print(f"STATE={s['state']}") + print(f"STATE={s['state']} (cycle {s.get('cycle', 1)})") print(f"feature: {s['feature']}") print(f"request: {s['request']}") + if s.get("branch"): + print(f"branch: {s['branch']}") + prior = s.get("increments", []) + if prior: + names = ", ".join(f"{i['cycle']}:{i['feature']}" for i in prior) + print(f"prior increments: {names}") print(f"in flight: {', '.join(s.get('in_flight', [])) or '-'}") decisions = ( len(DECISIONS_FILE.read_text().splitlines()) if DECISIONS_FILE.exists() else 0 ) print(f"decisions logged: {decisions}") for h in s["history"][-3:]: - print(f" {h['at']} → {h['to']}: {h['reason']}") + commit = f" [{h['commit']}]" if h.get("commit") else "" + print(f" {h['at']} → {h['to']}: {h['reason']}{commit}") def cmd_transition(args): @@ -121,22 +168,72 @@ def cmd_transition(args): f"Recording {s['state']} → {target} anyway — you're driving.", file=sys.stderr, ) - s["history"].append({"at": now(), "to": target, "reason": args.reason}) + s["branch"] = git_branch() or s.get("branch") + s["history"].append( + { + "at": now(), + "to": target, + "reason": args.reason, + "commit": git_head(), + "cycle": s.get("cycle", 1), + } + ) s["state"] = target save(s) append_progress(f"→ {target}: {args.reason}") print(f"OK {target}") +def cmd_increment(args): + # First-class "feature done, start the next increment". DONE is a terminal + # sink in the edge graph and init is resume-only, so without this the only + # path to increment 2 is an off-graph DONE→SPEC nudge that leaves feature/ + # request stale and the records undelimited. This archives the finished + # increment, retargets the session, bumps the cycle so records stay grouped, + # and resets to INIT so the normal INIT→SPEC edge applies with no nudge. + s = load() + prev_cycle = s.get("cycle", 1) + s.setdefault("increments", []).append( + { + "cycle": prev_cycle, + "feature": s["feature"], + "request": s["request"], + "ended_state": s["state"], + "at": now(), + } + ) + new_cycle = prev_cycle + 1 + s["cycle"] = new_cycle + s["feature"] = args.feature + s["request"] = args.request + s["state"] = "INIT" + s["in_flight"] = [] + s["branch"] = git_branch() or s.get("branch") + s["history"].append( + { + "at": now(), + "to": "INIT", + "reason": f"increment {new_cycle}: {args.feature}", + "commit": git_head(), + "cycle": new_cycle, + } + ) + save(s) + append_progress(f"━━ increment {new_cycle}: {args.feature} — {args.request}") + print(f"OK increment {new_cycle}: state INIT, feature={args.feature}") + + def cmd_decide(args): s = load() SDLC_DIR.mkdir(exist_ok=True) entry = { "at": now(), "state": s["state"], + "cycle": s.get("cycle", 1), "decision": args.decision, "why": args.why, "reversible": not args.irreversible, + "commit": git_head(), } with DECISIONS_FILE.open("a") as f: f.write(json.dumps(entry) + "\n") @@ -166,16 +263,28 @@ def cmd_journal(_args): s = load() print(f"# Session journal: {s['feature']}") print(f"Request: {s['request']}") - print(f"State: {s['state']}\n") + print(f"State: {s['state']} (cycle {s.get('cycle', 1)})\n") print("## Phase history") + last_cycle = None for h in s["history"]: - print(f"- {h['at']} → {h['to']}: {h['reason']}") + c = h.get("cycle", 1) + if c != last_cycle: + print(f"### increment {c}") + last_cycle = c + commit = f" [{h['commit']}]" if h.get("commit") else "" + print(f"- {h['at']} → {h['to']}: {h['reason']}{commit}") print("\n## Decisions") if DECISIONS_FILE.exists(): + last_cycle = None for line in DECISIONS_FILE.read_text().splitlines(): d = json.loads(line) + c = d.get("cycle", 1) + if c != last_cycle: + print(f"### increment {c}") + last_cycle = c tag = "" if d.get("reversible", True) else " (irreversible)" - print(f"- [{d['state']}] {d['decision']} — {d['why']}{tag}") + commit = f" [{d['commit']}]" if d.get("commit") else "" + print(f"- [{d['state']}] {d['decision']} — {d['why']}{tag}{commit}") else: print("- (none logged)") @@ -217,6 +326,11 @@ def main(): sp.add_argument("--reason", required=True) sp.set_defaults(func=cmd_transition) + sp = sub.add_parser("increment", help="finish this increment, start the next") + sp.add_argument("--feature", required=True) + sp.add_argument("--request", default="") + sp.set_defaults(func=cmd_increment) + sp = sub.add_parser("decide", help="log a decision to decisions.jsonl") sp.add_argument("--decision", required=True) sp.add_argument("--why", required=True) diff --git a/plugins/stick-shift/scripts/test_session_state.py b/plugins/stick-shift/scripts/test_session_state.py index a17873e..b003456 100644 --- a/plugins/stick-shift/scripts/test_session_state.py +++ b/plugins/stick-shift/scripts/test_session_state.py @@ -170,3 +170,82 @@ def test_takeover_is_noop_without_autonomous_driver(tmp_path): _run(tmp_path, session_state.cmd_takeover) # Nothing to stand down → do not fabricate a driver key. assert "driver" not in _read_state(tmp_path) + + +# --- Finding A: record fidelity (commit SHA + branch + cycle per entry) --- + + +def test_init_records_branch_and_cycle(tmp_path): + state = _init(tmp_path) + # cycle starts at 1; branch + commit keys are present (None outside a git repo). + assert state["cycle"] == 1 + assert "branch" in state + assert state["history"][0]["cycle"] == 1 + assert "commit" in state["history"][0] + + +def test_transition_records_commit_and_cycle(tmp_path): + _init(tmp_path) + _run(tmp_path, session_state.cmd_transition, target="SPEC", reason="3 criteria") + entry = _read_state(tmp_path)["history"][-1] + assert "commit" in entry + assert entry["cycle"] == 1 + + +def test_decide_records_commit_and_cycle(tmp_path): + _init(tmp_path) + _run( + tmp_path, + session_state.cmd_decide, + decision="Decimal not float", + why="exact rounding", + irreversible=False, + ) + cwd = Path.cwd() + os.chdir(tmp_path) + try: + entry = json.loads(session_state.DECISIONS_FILE.read_text().splitlines()[0]) + finally: + os.chdir(cwd) + assert "commit" in entry + assert entry["cycle"] == 1 + + +# --- Finding B: the "next increment" lifecycle primitive --- + + +def test_increment_starts_new_cycle_and_retargets(tmp_path): + _init(tmp_path) # feature=cart-pricing, cycle 1 + _run(tmp_path, session_state.cmd_transition, target="SPEC", reason="done-ish") + _run( + tmp_path, + session_state.cmd_increment, + feature="durability", + request="persist to JSONL", + ) + state = _read_state(tmp_path) + assert state["cycle"] == 2 + assert state["feature"] == "durability" # feature retargeted, no stale lie + assert state["request"] == "persist to JSONL" + assert state["state"] == "INIT" # new increment begins at INIT + assert state["in_flight"] == [] + assert "increment 2" in state["history"][-1]["reason"] + + +def test_increment_resets_to_INIT_so_spec_is_on_graph(tmp_path, capsys): + _init(tmp_path) + _run(tmp_path, session_state.cmd_increment, feature="durability", request="x") + capsys.readouterr() # drain + # INIT → SPEC is a normal edge: starting increment 2's spec must NOT nudge. + _run(tmp_path, session_state.cmd_transition, target="SPEC", reason="inc2 spec") + assert "NUDGE" not in capsys.readouterr().err + + +def test_increment_archives_prior_increment(tmp_path): + _init(tmp_path, feature="cart-pricing", request="Build a cart engine") + _run(tmp_path, session_state.cmd_increment, feature="durability", request="x") + archived = _read_state(tmp_path)["increments"] + assert len(archived) == 1 + assert archived[0]["feature"] == "cart-pricing" + assert archived[0]["request"] == "Build a cart engine" + assert archived[0]["cycle"] == 1