Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
4 changes: 2 additions & 2 deletions plugins/stick-shift/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
18 changes: 17 additions & 1 deletion plugins/stick-shift/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <new> --request "<task>"`. 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 <entry-sha>..<next-sha>` recovers a phase's work). `status`
and `journal` surface the branch, cycle, and per-entry SHA.
7 changes: 7 additions & 0 deletions plugins/stick-shift/commands/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<task>"` — 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:
Expand Down
128 changes: 121 additions & 7 deletions plugins/stick-shift/scripts/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import argparse
import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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)
Expand All @@ -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):
Expand All @@ -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")
Expand Down Expand Up @@ -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)")

Expand Down Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions plugins/stick-shift/scripts/test_session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading