stepgate 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stepgate/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """stepgate: a step-gated micro-change protocol CLI for coding agents."""
2
+
3
+ __version__ = "0.1.0"
stepgate/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from stepgate.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
stepgate/cli.py ADDED
@@ -0,0 +1,126 @@
1
+ """stepgate command-line interface.
2
+
3
+ All commands are non-interactive by design (no prompts, no confirmations) so
4
+ they behave identically in a terminal, a desktop app, or an IDE side-panel
5
+ extension. User-facing errors are printed as clear messages, never as raw
6
+ Python tracebacks.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from stepgate import __version__
15
+ from stepgate.model import StepgateError
16
+
17
+
18
+ def _add_session_flags(parser: argparse.ArgumentParser) -> None:
19
+ parser.add_argument("--agent", help="calling agent name (e.g. claude, codex)")
20
+ parser.add_argument("--session", help="explicit session name to target")
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(
25
+ prog="stepgate",
26
+ description=(
27
+ "Step-gated micro-change protocol for coding agents: propose, "
28
+ "approve, execute, verify - one small step at a time. stepgate "
29
+ "structures the flow; it never blocks your code, your commits, "
30
+ "or your git."
31
+ ),
32
+ )
33
+ parser.add_argument("--version", action="version", version=f"stepgate {__version__}")
34
+ sub = parser.add_subparsers(dest="command", required=True)
35
+
36
+ p = sub.add_parser("init", help="create .stepgate/ and inject the agent instruction block")
37
+ p.add_argument("--guardrails", help="path to an existing domain guardrails doc to reference")
38
+
39
+ p = sub.add_parser("propose", help="register a micro-change plan (state: PENDING)")
40
+ p.add_argument("--file", required=True, help="JSON file with the six plan fields")
41
+ p.add_argument("--new-session", action="store_true", help="start a fresh session")
42
+ _add_session_flags(p)
43
+
44
+ p = sub.add_parser("show", help="show the active proposal rendered as prose")
45
+ _add_session_flags(p)
46
+
47
+ p = sub.add_parser("approve", help="PENDING -> APPROVED")
48
+ p.add_argument("--adjust", action="store_true", help="approve with an adjusted/reduced scope")
49
+ p.add_argument("--scope", help="comma-separated adjusted scope (files/areas)")
50
+ p.add_argument("--note", help="note describing the adjustment")
51
+ _add_session_flags(p)
52
+
53
+ p = sub.add_parser("reject", help="PENDING -> REJECTED")
54
+ p.add_argument("--note", required=True, help="reason for rejection")
55
+ _add_session_flags(p)
56
+
57
+ p = sub.add_parser("exec-log", help="APPROVED -> EXECUTED (consolidated summary)")
58
+ p.add_argument("--summary", required=True, help="what was done, consolidated")
59
+ p.add_argument("--files", help="comma-separated files that were touched")
60
+ _add_session_flags(p)
61
+
62
+ p = sub.add_parser("verify", help="EXECUTED -> VERIFIED")
63
+ p.add_argument("--evidence", required=True, help="tests/runs/evidence demonstrating the result")
64
+ _add_session_flags(p)
65
+
66
+ p = sub.add_parser("close", help="VERIFIED -> CLOSED")
67
+ _add_session_flags(p)
68
+
69
+ p = sub.add_parser("abandon", help="any non-terminal state -> ABANDONED")
70
+ p.add_argument("--reason", required=True, help="why the proposal is being abandoned")
71
+ _add_session_flags(p)
72
+
73
+ p = sub.add_parser("next", help="record a next-step suggestion (does not open a proposal)")
74
+ p.add_argument("--suggest", required=True, help="the suggested next step")
75
+ _add_session_flags(p)
76
+
77
+ p = sub.add_parser("status", help="current session state + aggregated project view")
78
+ _add_session_flags(p)
79
+
80
+ p = sub.add_parser("history", help="chronological, cross-session log")
81
+ p.add_argument("--session", help="filter by session name")
82
+ p.add_argument("--since", help="filter by ISO date (e.g. 2026-07-09)")
83
+
84
+ sub.add_parser("doctor", help="scan .stepgate/ and report problems (never fixes anything)")
85
+
86
+ return parser
87
+
88
+
89
+ def main(argv: list[str] | None = None) -> int:
90
+ # Consoles with limited encodings (e.g. cp1252 on legacy Windows) must
91
+ # degrade output gracefully, never crash with a UnicodeEncodeError.
92
+ for stream in (sys.stdout, sys.stderr):
93
+ if hasattr(stream, "reconfigure"):
94
+ try:
95
+ stream.reconfigure(errors="replace")
96
+ except (OSError, ValueError):
97
+ pass
98
+
99
+ args = build_parser().parse_args(argv)
100
+
101
+ from stepgate.commands import doctor, init_cmd, lifecycle, views
102
+
103
+ handlers = {
104
+ "init": init_cmd.cmd_init,
105
+ "propose": lifecycle.cmd_propose,
106
+ "show": views.cmd_show,
107
+ "approve": lifecycle.cmd_approve,
108
+ "reject": lifecycle.cmd_reject,
109
+ "exec-log": lifecycle.cmd_exec_log,
110
+ "verify": lifecycle.cmd_verify,
111
+ "close": lifecycle.cmd_close,
112
+ "abandon": lifecycle.cmd_abandon,
113
+ "next": lifecycle.cmd_next,
114
+ "status": views.cmd_status,
115
+ "history": views.cmd_history,
116
+ "doctor": doctor.cmd_doctor,
117
+ }
118
+ try:
119
+ return handlers[args.command](args)
120
+ except StepgateError as exc:
121
+ print(f"stepgate: error: {exc}", file=sys.stderr)
122
+ return 1
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,62 @@
1
+ """stepgate doctor: scan .stepgate/ and report problems without fixing
2
+ anything. A diagnostic tool, never a repair tool — and never a gate: even
3
+ when it finds problems, nothing else is blocked."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+
9
+ from stepgate import render
10
+ from stepgate.model import Session
11
+ from stepgate.store import Store
12
+
13
+
14
+ def cmd_doctor(args) -> int:
15
+ store = Store.find()
16
+ problems: list[str] = []
17
+
18
+ if store.config_path.exists():
19
+ try:
20
+ config = json.loads(store.config_path.read_text(encoding="utf-8"))
21
+ for key in ("project_name", "agents", "verify_command"):
22
+ if key not in config:
23
+ problems.append(f"{store.config_path}: missing expected key '{key}'")
24
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
25
+ problems.append(f"{store.config_path}: not valid JSON ({exc})")
26
+ else:
27
+ problems.append(f"{store.config_path}: missing (run 'stepgate init' to recreate it)")
28
+
29
+ if store.sessions_dir.is_dir():
30
+ for path in sorted(store.sessions_dir.glob("*.json")):
31
+ try:
32
+ Session.from_dict(json.loads(path.read_text(encoding="utf-8")))
33
+ except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError) as exc:
34
+ problems.append(f"{path}: corrupted or invalid ({exc.__class__.__name__}: {exc})")
35
+ else:
36
+ problems.append(f"{store.sessions_dir}: missing directory")
37
+
38
+ if store.history_path.exists():
39
+ for i, line in enumerate(
40
+ store.history_path.read_text(encoding="utf-8").splitlines(), start=1
41
+ ):
42
+ if not line.strip():
43
+ continue
44
+ try:
45
+ json.loads(line)
46
+ except json.JSONDecodeError:
47
+ problems.append(f"{store.history_path}: invalid JSON at line {i}")
48
+ else:
49
+ problems.append(f"{store.history_path}: missing file")
50
+
51
+ if not problems:
52
+ render.info(f"[green]doctor: no problems found in {store.dir}[/]")
53
+ return 0
54
+ render.info(f"[yellow]doctor: found {len(problems)} problem(s) in {store.dir}:[/]")
55
+ for problem in problems:
56
+ render.info(f" - {problem}")
57
+ render.info(
58
+ "[dim]stepgate never repairs state automatically - please inspect the "
59
+ "files above manually. Other sessions and normal project work are "
60
+ "not blocked by this.[/]"
61
+ )
62
+ return 1
@@ -0,0 +1,132 @@
1
+ """stepgate init: create .stepgate/ and inject the agent instruction block.
2
+
3
+ Idempotent and never destructive: existing sessions/, history.jsonl and any
4
+ hand-written content in AGENTS.md/CLAUDE.md are always preserved. Only the
5
+ text between the stepgate markers is ever replaced.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from stepgate import render
14
+ from stepgate.store import DEFAULT_CONFIG, Store
15
+
16
+ START_MARKER = "<!-- stepgate:start -->"
17
+ END_MARKER = "<!-- stepgate:end -->"
18
+
19
+ INSTRUCTION_FILES = ("AGENTS.md", "CLAUDE.md")
20
+ GUARDRAILS_CANDIDATES = ("GUARDRAILS.md", "docs/GUARDRAILS.md")
21
+
22
+
23
+ def build_instruction_block(guardrails: str | None) -> str:
24
+ guardrails_paragraph = (
25
+ f"Domain knowledge for this project lives in `{guardrails}`. That document "
26
+ "governs *what* is correct in this domain; stepgate governs *when* you may "
27
+ "execute a change. Both apply, each in its own dimension — neither replaces "
28
+ "the other.\n\n"
29
+ if guardrails
30
+ else ""
31
+ )
32
+ return f"""{START_MARKER}
33
+ ## Micro-change protocol (stepgate)
34
+
35
+ This project uses `stepgate`, a CLI that structures work into small, approved
36
+ steps. It is your toolset for proposing and executing changes — the commands
37
+ below are the only legitimate channel for that flow.
38
+
39
+ {guardrails_paragraph}**When it applies:** any change to code or behavior — implementation, bug fixes
40
+ that edit code, refactoring, contract/schema changes. It does NOT apply to
41
+ read-only work: investigating, explaining, diagnosing without editing.
42
+
43
+ **The cycle.** Investigate as deeply as you need first — a micro-change reduces
44
+ the scope of *execution*, never the depth of *investigation*. Then:
45
+
46
+ 1. `stepgate propose --agent <your-name> --file plan.json` — a JSON object with
47
+ six fields (`what`, `why`, `where`, `how`, `expected_result`,
48
+ `verification`), each written as natural, flowing prose (where is a list of
49
+ files/areas). State becomes PENDING. Always pass `--agent` (e.g. `claude`,
50
+ `codex`) so sessions stay readable.
51
+ 2. Wait for the user to run `stepgate approve` (possibly `--adjust`) or
52
+ `stepgate reject`. Never execute a PENDING proposal.
53
+ 3. Execute **only** what was approved, then record it:
54
+ `stepgate exec-log --summary "..." --files "a,b"`.
55
+ 4. Verify with real evidence: `stepgate verify --evidence "npm test: 12 passed"`.
56
+ 5. After the user runs `stepgate close`, suggest (don't start) the next step:
57
+ `stepgate next --suggest "..."`.
58
+
59
+ **Rules:**
60
+ - Approval is per micro-change, never cumulative. One approval is not a blanket
61
+ pass for the rest of the task.
62
+ - If your environment has an "apply change" / "accept diff" button in the IDE
63
+ UI, the same rule holds: only reach that point after the proposal was
64
+ approved via stepgate.
65
+ - If a proposal becomes obsolete, close it out explicitly:
66
+ `stepgate abandon --reason "..."`.
67
+ - Run `stepgate status`/`history` only when you actually need them (when
68
+ proposing, or when closing a cycle) — not as a habitual check.
69
+ - stepgate never blocks edits, commits, or the user. It records and makes the
70
+ flow visible; deviating from it is visible, never silent.
71
+ {END_MARKER}"""
72
+
73
+
74
+ def inject_block(path: Path, block: str) -> str:
75
+ """Create, append, or update-in-place the stepgate block. Returns the
76
+ action taken. Never discards existing content."""
77
+ if not path.exists():
78
+ path.write_text(block + "\n", encoding="utf-8")
79
+ return "created"
80
+ content = path.read_text(encoding="utf-8")
81
+ if START_MARKER in content and END_MARKER in content:
82
+ start = content.index(START_MARKER)
83
+ end = content.index(END_MARKER) + len(END_MARKER)
84
+ path.write_text(content[:start] + block + content[end:], encoding="utf-8")
85
+ return "updated"
86
+ separator = "" if content.endswith("\n\n") else ("\n" if content.endswith("\n") else "\n\n")
87
+ path.write_text(content + separator + block + "\n", encoding="utf-8")
88
+ return "appended"
89
+
90
+
91
+ def detect_guardrails(root: Path) -> str | None:
92
+ for candidate in GUARDRAILS_CANDIDATES:
93
+ if (root / candidate).exists():
94
+ return candidate
95
+ return None
96
+
97
+
98
+ def cmd_init(args) -> int:
99
+ root = Path.cwd()
100
+ store = Store(root)
101
+ store.ensure_layout()
102
+
103
+ if not store.config_path.exists():
104
+ config = dict(DEFAULT_CONFIG)
105
+ config["project_name"] = root.name
106
+ store.write_config(config)
107
+ render.info(f"Created {store.config_path}")
108
+ else:
109
+ render.info(f"Kept existing {store.config_path}")
110
+
111
+ guardrails = args.guardrails or detect_guardrails(root)
112
+ if guardrails and not (root / guardrails).exists():
113
+ render.warn(f"guardrails file '{guardrails}' does not exist; referencing it anyway.")
114
+ block = build_instruction_block(guardrails)
115
+ for name in INSTRUCTION_FILES:
116
+ action = inject_block(root / name, block)
117
+ render.info(f"{action.capitalize()} stepgate instruction block in {name}")
118
+ if guardrails:
119
+ render.info(f"Instruction block references domain guardrails: {guardrails}")
120
+
121
+ if shutil.which("stepgate") is None:
122
+ render.warn(
123
+ "the 'stepgate' executable was not found on PATH from this "
124
+ "environment. Agents running here will not be able to call it - "
125
+ "check the installation (pipx install stepgate) or this "
126
+ "environment's PATH."
127
+ )
128
+ render.info(
129
+ f"[green]stepgate initialized in {store.dir}[/] "
130
+ "(existing sessions and history, if any, were preserved)."
131
+ )
132
+ return 0
@@ -0,0 +1,238 @@
1
+ """Lifecycle commands: propose, approve, reject, exec-log, verify, close,
2
+ abandon, next. These validate the proposal state machine; they never block
3
+ or restrict edits to the project's own code."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from stepgate import render
12
+ from stepgate.model import (
13
+ APPROVED,
14
+ PENDING,
15
+ Proposal,
16
+ Session,
17
+ StepgateError,
18
+ validate_plan,
19
+ )
20
+ from stepgate.store import Store
21
+
22
+ UNKNOWN_AGENT = "unknown"
23
+
24
+
25
+ def _agent_or_fallback(args) -> str:
26
+ return args.agent or UNKNOWN_AGENT
27
+
28
+
29
+ def resolve_session(store: Store, args, *, active_only: bool = True) -> Session:
30
+ """Resolve which session a command targets.
31
+
32
+ Priority: explicit --session > latest session of --agent > the single
33
+ session with an active proposal, if there is exactly one.
34
+ """
35
+ if getattr(args, "session", None):
36
+ return store.load_session(args.session)
37
+ if args.agent:
38
+ session = store.latest_session_for_agent(args.agent)
39
+ if session is None:
40
+ raise StepgateError(
41
+ f"No session found for agent '{args.agent}'. "
42
+ "Start one with 'stepgate propose'."
43
+ )
44
+ return session
45
+ candidates = [s for s in store.iter_sessions() if not active_only or s.has_active_proposal]
46
+ if len(candidates) == 1:
47
+ return candidates[0]
48
+ if not candidates:
49
+ raise StepgateError(
50
+ "No session with an active proposal was found. "
51
+ "Start one with 'stepgate propose'."
52
+ )
53
+ names = ", ".join(s.name for s in candidates)
54
+ raise StepgateError(
55
+ f"Multiple sessions are active ({names}). "
56
+ "Pass --agent <name> or --session <name> to pick one."
57
+ )
58
+
59
+
60
+ def _overlap_warnings(store: Store, current: Session) -> None:
61
+ """Warn (never block) when active proposals across sessions share files."""
62
+ if not current.has_active_proposal:
63
+ return
64
+ mine = set(current.proposal.plan["where"])
65
+ for other in store.iter_sessions():
66
+ if other.name == current.name or not other.has_active_proposal:
67
+ continue
68
+ if other.proposal.state not in (PENDING, APPROVED):
69
+ continue
70
+ shared = mine & set(other.proposal.plan["where"])
71
+ if shared:
72
+ render.warn(
73
+ f"scope overlap with session '{other.name}' "
74
+ f"({other.proposal.state}): {', '.join(sorted(shared))}. "
75
+ "This is informational only - nothing is blocked."
76
+ )
77
+
78
+
79
+ def _transition(store: Store, args, action: str, data: dict) -> Session:
80
+ session = resolve_session(store, args)
81
+ if session.proposal is None:
82
+ raise StepgateError(
83
+ f"Session '{session.name}' has no proposal. "
84
+ "Start one with 'stepgate propose'."
85
+ )
86
+ new_state = session.proposal.apply(action, data)
87
+ store.save_session(session)
88
+ store.append_history(session.name, session.agent, action, data)
89
+ render.info(
90
+ f"[bold magenta]{session.name}[/] :: {action} -> "
91
+ f"[{render.STATE_STYLES.get(new_state, 'bold')}]{new_state}[/]"
92
+ )
93
+ return session
94
+
95
+
96
+ # -- commands ---------------------------------------------------------------
97
+
98
+
99
+ def cmd_propose(args) -> int:
100
+ store = Store.find()
101
+ plan_path = Path(args.file)
102
+ if not plan_path.exists():
103
+ raise StepgateError(f"Plan file not found: {plan_path}")
104
+ try:
105
+ raw = json.loads(plan_path.read_text(encoding="utf-8"))
106
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
107
+ raise StepgateError(f"Plan file is not valid JSON: {plan_path} ({exc})") from exc
108
+ plan = validate_plan(raw)
109
+
110
+ agent = _agent_or_fallback(args)
111
+ if not args.agent:
112
+ render.warn(
113
+ "no --agent flag given; using the fallback session name "
114
+ f"'{UNKNOWN_AGENT}-...'. Pass --agent claude|codex|... to keep "
115
+ "session names readable."
116
+ )
117
+
118
+ session = None if args.new_session else store.latest_session_for_agent(agent)
119
+ if session is not None and session.has_active_proposal:
120
+ raise StepgateError(
121
+ f"Session '{session.name}' already has an active proposal in state "
122
+ f"{session.proposal.state}. Close, reject, or abandon it first - "
123
+ "or pass --new-session to start a parallel session."
124
+ )
125
+ if session is None:
126
+ session = Session(name=store.new_session_name(agent), agent=agent)
127
+
128
+ session.proposal = Proposal(plan=plan)
129
+ session.pending_suggestion = None
130
+ store.save_session(session)
131
+ store.append_history(session.name, session.agent, "propose", {"plan": plan})
132
+
133
+ render.console.print(render.proposal_panel(session.name, session.proposal.to_dict()))
134
+ render.info(
135
+ f"Proposal registered as [bold yellow]PENDING[/] in session "
136
+ f"[bold magenta]{session.name}[/]. Waiting for approval "
137
+ "('stepgate approve' or 'stepgate reject')."
138
+ )
139
+ _overlap_warnings(store, session)
140
+ return 0
141
+
142
+
143
+ def cmd_approve(args) -> int:
144
+ store = Store.find()
145
+ data: dict = {}
146
+ if args.adjust:
147
+ if args.scope:
148
+ data["scope"] = [p.strip() for p in args.scope.split(",") if p.strip()]
149
+ if args.note:
150
+ data["note"] = args.note
151
+ if not data:
152
+ raise StepgateError("--adjust requires --scope and/or --note describing the adjustment.")
153
+ session = _transition(store, args, "approve", data)
154
+ if args.adjust and data.get("scope"):
155
+ session.proposal.plan["where"] = data["scope"]
156
+ store.save_session(session)
157
+ render.info(
158
+ "Approval covers this micro-change only - it is not a blanket "
159
+ "approval for the rest of the task."
160
+ )
161
+ return 0
162
+
163
+
164
+ def cmd_reject(args) -> int:
165
+ store = Store.find()
166
+ _transition(store, args, "reject", {"note": args.note})
167
+ return 0
168
+
169
+
170
+ def _git_diff_stat(root: Path) -> str | None:
171
+ """Best-effort git diff --stat capture. Never raises, never blocks."""
172
+ try:
173
+ result = subprocess.run(
174
+ ["git", "diff", "--stat", "HEAD"],
175
+ cwd=root, capture_output=True, text=True, timeout=15,
176
+ )
177
+ if result.returncode == 0 and result.stdout.strip():
178
+ return result.stdout.strip()
179
+ except (OSError, subprocess.SubprocessError):
180
+ pass
181
+ return None
182
+
183
+
184
+ def cmd_exec_log(args) -> int:
185
+ store = Store.find()
186
+ data: dict = {"summary": args.summary}
187
+ if args.files:
188
+ data["files"] = [p.strip() for p in args.files.split(",") if p.strip()]
189
+ diff_stat = _git_diff_stat(store.root)
190
+ if diff_stat is not None:
191
+ data["git_diff_stat"] = diff_stat
192
+ _transition(store, args, "exec-log", data)
193
+ if diff_stat:
194
+ render.console.print(
195
+ "[dim]git diff --stat captured as objective evidence:[/]\n" + diff_stat
196
+ )
197
+ return 0
198
+
199
+
200
+ def cmd_verify(args) -> int:
201
+ store = Store.find()
202
+ _transition(store, args, "verify", {"evidence": args.evidence})
203
+ return 0
204
+
205
+
206
+ def cmd_close(args) -> int:
207
+ store = Store.find()
208
+ session = _transition(store, args, "close", {})
209
+ session.closed_proposals += 1
210
+ store.save_session(session)
211
+ render.info(
212
+ "Micro-change closed. Suggest the next step with 'stepgate next "
213
+ "--suggest \"...\"' - but do not start it without a new approved proposal."
214
+ )
215
+ return 0
216
+
217
+
218
+ def cmd_abandon(args) -> int:
219
+ store = Store.find()
220
+ _transition(store, args, "abandon", {"reason": args.reason})
221
+ render.info(
222
+ "Session left cleanly. Nothing in the project or in other sessions "
223
+ "is affected."
224
+ )
225
+ return 0
226
+
227
+
228
+ def cmd_next(args) -> int:
229
+ store = Store.find()
230
+ session = resolve_session(store, args, active_only=False)
231
+ session.pending_suggestion = args.suggest
232
+ store.save_session(session)
233
+ store.append_history(session.name, session.agent, "next-suggest", {"suggestion": args.suggest})
234
+ render.info(
235
+ f"Next-step suggestion recorded in session [bold magenta]{session.name}[/]. "
236
+ "It will stay visible in 'stepgate status' until a new proposal is opened."
237
+ )
238
+ return 0
@@ -0,0 +1,129 @@
1
+ """Read-only views: show, status, history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+ from stepgate import render
11
+ from stepgate.commands.lifecycle import resolve_session
12
+ from stepgate.model import APPROVED, PENDING, StepgateError, TERMINAL_STATES
13
+ from stepgate.store import Store
14
+
15
+
16
+ def cmd_show(args) -> int:
17
+ store = Store.find()
18
+ session = resolve_session(store, args, active_only=False)
19
+ if session.proposal is None:
20
+ raise StepgateError(f"Session '{session.name}' has no proposal to show.")
21
+ render.console.print(render.proposal_panel(session.name, session.proposal.to_dict()))
22
+ return 0
23
+
24
+
25
+ def _overlaps(sessions) -> list[str]:
26
+ active = [s for s in sessions if s.has_active_proposal and s.proposal.state in (PENDING, APPROVED)]
27
+ messages = []
28
+ for i, a in enumerate(active):
29
+ for b in active[i + 1:]:
30
+ shared = set(a.proposal.plan["where"]) & set(b.proposal.plan["where"])
31
+ if shared:
32
+ messages.append(
33
+ f"sessions '{a.name}' and '{b.name}' both touch: "
34
+ + ", ".join(sorted(shared))
35
+ )
36
+ return messages
37
+
38
+
39
+ def cmd_status(args) -> int:
40
+ store = Store.find()
41
+ sessions = list(store.iter_sessions())
42
+ if not sessions:
43
+ render.info("No sessions yet. Start one with 'stepgate propose'.")
44
+ return 0
45
+
46
+ table = Table(title="stepgate :: project status", border_style="dim")
47
+ table.add_column("Session", style="magenta")
48
+ table.add_column("State")
49
+ table.add_column("Closed", justify="right")
50
+ table.add_column("Last activity", style="dim")
51
+ for session in sessions:
52
+ if session.proposal is None:
53
+ state = Text("no proposal", style="dim")
54
+ last = session.created_at
55
+ else:
56
+ state = render.state_text(session.proposal.state)
57
+ last = session.proposal.updated_at
58
+ table.add_row(session.name, state, str(session.closed_proposals), last)
59
+ render.console.print(table)
60
+
61
+ focus = None
62
+ if getattr(args, "session", None) or args.agent:
63
+ focus = resolve_session(store, args, active_only=False)
64
+ elif len(sessions) == 1:
65
+ focus = sessions[0]
66
+ if focus is not None:
67
+ if focus.pending_suggestion:
68
+ render.console.print(
69
+ Text.assemble(
70
+ ("Suggested next step", "bold green"),
71
+ (f" ({focus.name}): ", "dim"),
72
+ focus.pending_suggestion,
73
+ )
74
+ )
75
+ if focus.proposal and focus.proposal.state not in TERMINAL_STATES:
76
+ render.console.print(render.proposal_panel(focus.name, focus.proposal.to_dict()))
77
+ else:
78
+ for session in sessions:
79
+ if session.pending_suggestion:
80
+ render.console.print(
81
+ Text.assemble(
82
+ ("Suggested next step", "bold green"),
83
+ (f" ({session.name}): ", "dim"),
84
+ session.pending_suggestion,
85
+ )
86
+ )
87
+
88
+ for message in _overlaps(sessions):
89
+ render.warn("scope overlap - " + message + ". Informational only, nothing is blocked.")
90
+ return 0
91
+
92
+
93
+ def cmd_history(args) -> int:
94
+ store = Store.find()
95
+ entries = store.read_history()
96
+ if args.session:
97
+ entries = [e for e in entries if e.get("session") == args.session]
98
+ if args.since:
99
+ try:
100
+ since = datetime.fromisoformat(args.since)
101
+ except ValueError as exc:
102
+ raise StepgateError(
103
+ f"--since must be an ISO date like 2026-07-09 (got '{args.since}')."
104
+ ) from exc
105
+ if since.tzinfo is None:
106
+ since = since.astimezone()
107
+ entries = [
108
+ e for e in entries
109
+ if datetime.fromisoformat(e["ts"]) >= since
110
+ ]
111
+ if not entries:
112
+ render.info("No history entries match.")
113
+ return 0
114
+ table = Table(title="stepgate :: history (append-only)", border_style="dim")
115
+ table.add_column("When", style="dim", no_wrap=True)
116
+ table.add_column("Session", style="magenta", no_wrap=True)
117
+ table.add_column("Event")
118
+ table.add_column("Detail", overflow="fold")
119
+ for entry in entries:
120
+ data = entry.get("data") or {}
121
+ detail = (
122
+ data.get("summary") or data.get("evidence") or data.get("note")
123
+ or data.get("reason") or data.get("suggestion")
124
+ or (data.get("plan", {}).get("what") if isinstance(data.get("plan"), dict) else "")
125
+ or ""
126
+ )
127
+ table.add_row(entry["ts"], entry["session"], entry["event"], detail)
128
+ render.console.print(table)
129
+ return 0
stepgate/model.py ADDED
@@ -0,0 +1,188 @@
1
+ """Data model and state machine for stepgate proposals.
2
+
3
+ The state machine governs the lifecycle of a *proposal*, never the code
4
+ itself. Editing code without an open proposal is a legitimate flow and is
5
+ never treated as an error by stepgate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any
13
+
14
+ # Proposal states
15
+ PENDING = "PENDING"
16
+ APPROVED = "APPROVED"
17
+ EXECUTED = "EXECUTED"
18
+ VERIFIED = "VERIFIED"
19
+ CLOSED = "CLOSED"
20
+ REJECTED = "REJECTED"
21
+ ABANDONED = "ABANDONED"
22
+
23
+ TERMINAL_STATES = {CLOSED, REJECTED, ABANDONED}
24
+
25
+ # action -> (required current state, next state)
26
+ TRANSITIONS = {
27
+ "approve": (PENDING, APPROVED),
28
+ "reject": (PENDING, REJECTED),
29
+ "exec-log": (APPROVED, EXECUTED),
30
+ "verify": (EXECUTED, VERIFIED),
31
+ "close": (VERIFIED, CLOSED),
32
+ }
33
+
34
+ PLAN_FIELDS = ("what", "why", "where", "how", "expected_result", "verification")
35
+
36
+ PLAN_FIELD_LABELS = {
37
+ "what": "What",
38
+ "why": "Why",
39
+ "where": "Where",
40
+ "how": "How",
41
+ "expected_result": "Expected result",
42
+ "verification": "Verification",
43
+ }
44
+
45
+
46
+ class StepgateError(Exception):
47
+ """User-facing error. The CLI prints its message without a traceback."""
48
+
49
+
50
+ class InvalidTransitionError(StepgateError):
51
+ pass
52
+
53
+
54
+ class CorruptStateError(StepgateError):
55
+ """Raised when a state file on disk cannot be read or parsed."""
56
+
57
+
58
+ def now_iso() -> str:
59
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
60
+
61
+
62
+ def validate_plan(raw: Any) -> dict[str, Any]:
63
+ """Validate a plan document: all six fields present and non-empty.
64
+
65
+ ``where`` may be a list of files/areas or a comma-separated string;
66
+ it is normalized to a list. All other fields are free-flowing prose.
67
+ """
68
+ if not isinstance(raw, dict):
69
+ raise StepgateError("The plan file must contain a JSON object.")
70
+ plan: dict[str, Any] = {}
71
+ missing = []
72
+ for name in PLAN_FIELDS:
73
+ value = raw.get(name)
74
+ if name == "where":
75
+ if isinstance(value, str):
76
+ value = [p.strip() for p in value.split(",") if p.strip()]
77
+ if not isinstance(value, list) or not value:
78
+ missing.append(name)
79
+ continue
80
+ plan[name] = [str(v) for v in value]
81
+ else:
82
+ if not isinstance(value, str) or not value.strip():
83
+ missing.append(name)
84
+ continue
85
+ plan[name] = value.strip()
86
+ if missing:
87
+ raise StepgateError(
88
+ "The plan is missing required fields: "
89
+ + ", ".join(missing)
90
+ + ". A micro-change plan must cover all six points "
91
+ "(what, why, where, how, expected_result, verification) "
92
+ "written as natural, flowing prose."
93
+ )
94
+ unknown = [k for k in raw if k not in PLAN_FIELDS]
95
+ if unknown:
96
+ raise StepgateError(
97
+ "The plan contains unknown fields: " + ", ".join(sorted(unknown))
98
+ )
99
+ return plan
100
+
101
+
102
+ @dataclass
103
+ class Proposal:
104
+ plan: dict[str, Any]
105
+ state: str = PENDING
106
+ created_at: str = field(default_factory=now_iso)
107
+ updated_at: str = field(default_factory=now_iso)
108
+ events: list[dict[str, Any]] = field(default_factory=list)
109
+
110
+ def apply(self, action: str, data: dict[str, Any] | None = None) -> str:
111
+ """Apply a lifecycle action, validating the state machine."""
112
+ if action == "abandon":
113
+ if self.state in TERMINAL_STATES:
114
+ raise InvalidTransitionError(
115
+ f"Cannot abandon a proposal in terminal state {self.state}."
116
+ )
117
+ new_state = ABANDONED
118
+ else:
119
+ if action not in TRANSITIONS:
120
+ raise InvalidTransitionError(f"Unknown action: {action}")
121
+ required, new_state = TRANSITIONS[action]
122
+ if self.state != required:
123
+ raise InvalidTransitionError(
124
+ f"'{action}' requires a proposal in state {required}, "
125
+ f"but the active proposal is {self.state}."
126
+ )
127
+ self.state = new_state
128
+ self.updated_at = now_iso()
129
+ self.events.append({"ts": self.updated_at, "action": action, "data": data or {}})
130
+ return new_state
131
+
132
+ def to_dict(self) -> dict[str, Any]:
133
+ return {
134
+ "plan": self.plan,
135
+ "state": self.state,
136
+ "created_at": self.created_at,
137
+ "updated_at": self.updated_at,
138
+ "events": self.events,
139
+ }
140
+
141
+ @classmethod
142
+ def from_dict(cls, raw: dict[str, Any]) -> "Proposal":
143
+ return cls(
144
+ plan=raw["plan"],
145
+ state=raw["state"],
146
+ created_at=raw.get("created_at", now_iso()),
147
+ updated_at=raw.get("updated_at", now_iso()),
148
+ events=raw.get("events", []),
149
+ )
150
+
151
+
152
+ @dataclass
153
+ class Session:
154
+ name: str
155
+ agent: str
156
+ created_at: str = field(default_factory=now_iso)
157
+ proposal: Proposal | None = None
158
+ pending_suggestion: str | None = None
159
+ closed_proposals: int = 0
160
+
161
+ @property
162
+ def has_active_proposal(self) -> bool:
163
+ return self.proposal is not None and self.proposal.state not in TERMINAL_STATES
164
+
165
+ def to_dict(self) -> dict[str, Any]:
166
+ return {
167
+ "session": self.name,
168
+ "agent": self.agent,
169
+ "created_at": self.created_at,
170
+ "proposal": self.proposal.to_dict() if self.proposal else None,
171
+ "pending_suggestion": self.pending_suggestion,
172
+ "closed_proposals": self.closed_proposals,
173
+ }
174
+
175
+ @classmethod
176
+ def from_dict(cls, raw: dict[str, Any]) -> "Session":
177
+ for key in ("session", "agent"):
178
+ if not isinstance(raw.get(key), str):
179
+ raise KeyError(key)
180
+ proposal = raw.get("proposal")
181
+ return cls(
182
+ name=raw["session"],
183
+ agent=raw["agent"],
184
+ created_at=raw.get("created_at", now_iso()),
185
+ proposal=Proposal.from_dict(proposal) if proposal else None,
186
+ pending_suggestion=raw.get("pending_suggestion"),
187
+ closed_proposals=raw.get("closed_proposals", 0),
188
+ )
stepgate/render.py ADDED
@@ -0,0 +1,83 @@
1
+ """Terminal rendering helpers.
2
+
3
+ Proposals are rendered as flowing, readable prose — the same natural
4
+ language the agent wrote them in — never as a rigid telegraphic form.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from rich.console import Console, Group
12
+ from rich.panel import Panel
13
+ from rich.text import Text
14
+
15
+ console = Console()
16
+ err_console = Console(stderr=True, style="yellow")
17
+
18
+ STATE_STYLES = {
19
+ "PENDING": "bold yellow",
20
+ "APPROVED": "bold cyan",
21
+ "EXECUTED": "bold blue",
22
+ "VERIFIED": "bold green",
23
+ "CLOSED": "bold dim green",
24
+ "REJECTED": "bold red",
25
+ "ABANDONED": "bold dim",
26
+ }
27
+
28
+
29
+ def state_text(state: str) -> Text:
30
+ return Text(state, style=STATE_STYLES.get(state, "bold"))
31
+
32
+
33
+ def plan_as_prose(plan: dict[str, Any]) -> Group:
34
+ """Render the six plan fields as connected paragraphs of prose."""
35
+ where = ", ".join(plan["where"])
36
+ paragraphs = [
37
+ Text(plan["what"]),
38
+ Text(plan["why"], style="dim"),
39
+ Text.assemble(("Touches: ", "italic"), where),
40
+ Text(plan["how"]),
41
+ Text.assemble(("Expected result: ", "italic"), plan["expected_result"]),
42
+ Text.assemble(("Verification: ", "italic"), plan["verification"]),
43
+ ]
44
+ spaced: list[Any] = []
45
+ for para in paragraphs:
46
+ spaced.append(para)
47
+ spaced.append(Text(""))
48
+ return Group(*spaced[:-1])
49
+
50
+
51
+ def proposal_panel(session_name: str, proposal: dict[str, Any]) -> Panel:
52
+ title = Text.assemble(
53
+ ("Proposal :: ", "bold"), (session_name, "bold magenta"), (" :: ", "bold"),
54
+ state_text(proposal["state"]),
55
+ )
56
+ body: list[Any] = [plan_as_prose(proposal["plan"])]
57
+ notes = []
58
+ for event in proposal.get("events", []):
59
+ data = event.get("data") or {}
60
+ if event["action"] == "approve" and (data.get("note") or data.get("scope")):
61
+ note = data.get("note", "")
62
+ scope = data.get("scope")
63
+ detail = f"Approved with adjustment: {note}" if note else "Approved with adjusted scope"
64
+ if scope:
65
+ detail += f" (scope: {', '.join(scope)})"
66
+ notes.append(detail)
67
+ elif event["action"] == "reject":
68
+ notes.append(f"Rejected: {data.get('note', '')}")
69
+ elif event["action"] == "abandon":
70
+ notes.append(f"Abandoned: {data.get('reason', '')}")
71
+ if notes:
72
+ body.append(Text(""))
73
+ for note in notes:
74
+ body.append(Text(f"- {note}", style="yellow"))
75
+ return Panel(Group(*body), title=title, title_align="left", border_style="dim")
76
+
77
+
78
+ def warn(message: str) -> None:
79
+ err_console.print(f"warning: {message}")
80
+
81
+
82
+ def info(message: str) -> None:
83
+ console.print(message)
stepgate/store.py ADDED
@@ -0,0 +1,166 @@
1
+ """On-disk state for stepgate: .stepgate/ directory, sessions, history.
2
+
3
+ The file lock protects only stepgate's own internal state under .stepgate/.
4
+ It never touches, locks, or otherwise restricts the project's source code,
5
+ the user's editor, or git.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from datetime import date
13
+ from pathlib import Path
14
+ from typing import Any, Iterator
15
+
16
+ from filelock import FileLock
17
+
18
+ from stepgate.model import CorruptStateError, Session, StepgateError, now_iso
19
+
20
+ STEPGATE_DIR = ".stepgate"
21
+ SESSION_NAME_RE = re.compile(r"^(?P<agent>.+)-(?P<date>\d{4}-\d{2}-\d{2})-(?P<seq>\d+)$")
22
+
23
+ DEFAULT_CONFIG = {
24
+ "project_name": "",
25
+ "agents": ["claude", "codex"],
26
+ "verify_command": "",
27
+ }
28
+
29
+
30
+ class Store:
31
+ def __init__(self, root: Path):
32
+ self.root = root
33
+ self.dir = root / STEPGATE_DIR
34
+ self.sessions_dir = self.dir / "sessions"
35
+ self.history_path = self.dir / "history.jsonl"
36
+ self.config_path = self.dir / "config.json"
37
+ self.lock = FileLock(str(self.dir / ".lock"), timeout=10)
38
+
39
+ # -- discovery ---------------------------------------------------------
40
+
41
+ @classmethod
42
+ def find(cls, start: Path | None = None) -> "Store":
43
+ """Locate .stepgate/ in the current directory or any parent."""
44
+ current = (start or Path.cwd()).resolve()
45
+ for candidate in [current, *current.parents]:
46
+ if (candidate / STEPGATE_DIR).is_dir():
47
+ return cls(candidate)
48
+ raise StepgateError(
49
+ "No .stepgate/ directory found in this directory or any parent. "
50
+ "Run 'stepgate init' at the root of the project first."
51
+ )
52
+
53
+ def ensure_layout(self) -> None:
54
+ self.dir.mkdir(exist_ok=True)
55
+ self.sessions_dir.mkdir(exist_ok=True)
56
+ if not self.history_path.exists():
57
+ self.history_path.touch()
58
+
59
+ # -- config ------------------------------------------------------------
60
+
61
+ def load_config(self) -> dict[str, Any]:
62
+ if not self.config_path.exists():
63
+ return dict(DEFAULT_CONFIG)
64
+ try:
65
+ return json.loads(self.config_path.read_text(encoding="utf-8"))
66
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
67
+ raise CorruptStateError(
68
+ f"Config file is not valid JSON: {self.config_path}\n"
69
+ f" ({exc})\n"
70
+ "Please inspect and fix it manually; stepgate never rewrites "
71
+ "state on its own."
72
+ ) from exc
73
+
74
+ def write_config(self, config: dict[str, Any]) -> None:
75
+ with self.lock:
76
+ self.config_path.write_text(
77
+ json.dumps(config, indent=2, ensure_ascii=False) + "\n",
78
+ encoding="utf-8",
79
+ )
80
+
81
+ # -- sessions ----------------------------------------------------------
82
+
83
+ def session_path(self, name: str) -> Path:
84
+ return self.sessions_dir / f"{name}.json"
85
+
86
+ def load_session(self, name: str) -> Session:
87
+ path = self.session_path(name)
88
+ if not path.exists():
89
+ raise StepgateError(f"Session '{name}' does not exist under {self.sessions_dir}.")
90
+ return self._read_session_file(path)
91
+
92
+ def _read_session_file(self, path: Path) -> Session:
93
+ try:
94
+ raw = json.loads(path.read_text(encoding="utf-8"))
95
+ return Session.from_dict(raw)
96
+ except (json.JSONDecodeError, UnicodeDecodeError, KeyError, TypeError) as exc:
97
+ raise CorruptStateError(
98
+ f"Session file is corrupted or invalid: {path}\n"
99
+ f" ({exc.__class__.__name__}: {exc})\n"
100
+ "Please inspect it manually; stepgate never repairs or "
101
+ "rewrites state on its own. Other sessions are unaffected."
102
+ ) from exc
103
+
104
+ def iter_sessions(self) -> Iterator[Session]:
105
+ """Yield all sessions, sorted by name. Corrupt files raise."""
106
+ for path in sorted(self.sessions_dir.glob("*.json")):
107
+ yield self._read_session_file(path)
108
+
109
+ def save_session(self, session: Session) -> None:
110
+ with self.lock:
111
+ self.session_path(session.name).write_text(
112
+ json.dumps(session.to_dict(), indent=2, ensure_ascii=False) + "\n",
113
+ encoding="utf-8",
114
+ )
115
+
116
+ def new_session_name(self, agent: str) -> str:
117
+ """Allocate the next human-readable session name for today."""
118
+ today = date.today().isoformat()
119
+ prefix = f"{agent}-{today}-"
120
+ seqs = [
121
+ int(m.group("seq"))
122
+ for p in self.sessions_dir.glob(f"{agent}-{today}-*.json")
123
+ if (m := SESSION_NAME_RE.match(p.stem)) and m.group("agent") == agent
124
+ ]
125
+ return f"{prefix}{max(seqs, default=0) + 1}"
126
+
127
+ def latest_session_for_agent(self, agent: str) -> Session | None:
128
+ paths = sorted(
129
+ (p for p in self.sessions_dir.glob(f"{agent}-*.json")
130
+ if (m := SESSION_NAME_RE.match(p.stem)) and m.group("agent") == agent),
131
+ key=lambda p: p.stat().st_mtime,
132
+ )
133
+ if not paths:
134
+ return None
135
+ return self._read_session_file(paths[-1])
136
+
137
+ # -- history (append-only) ----------------------------------------------
138
+
139
+ def append_history(self, session: str, agent: str, event: str, data: dict[str, Any]) -> None:
140
+ """Append one event line. history.jsonl is never rewritten or edited."""
141
+ line = json.dumps(
142
+ {"ts": now_iso(), "session": session, "agent": agent, "event": event, "data": data},
143
+ ensure_ascii=False,
144
+ )
145
+ with self.lock:
146
+ with self.history_path.open("a", encoding="utf-8") as fh:
147
+ fh.write(line + "\n")
148
+
149
+ def read_history(self) -> list[dict[str, Any]]:
150
+ if not self.history_path.exists():
151
+ return []
152
+ entries = []
153
+ for i, line in enumerate(
154
+ self.history_path.read_text(encoding="utf-8").splitlines(), start=1
155
+ ):
156
+ if not line.strip():
157
+ continue
158
+ try:
159
+ entries.append(json.loads(line))
160
+ except json.JSONDecodeError as exc:
161
+ raise CorruptStateError(
162
+ f"History file has an invalid entry at line {i}: {self.history_path}\n"
163
+ f" ({exc})\n"
164
+ "Please inspect it manually; stepgate never rewrites history."
165
+ ) from exc
166
+ return entries
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: stepgate
3
+ Version: 0.1.0
4
+ Summary: A step-gated micro-change protocol CLI for coding agents: propose, approve, execute, verify — one small step at a time.
5
+ Project-URL: Homepage, https://github.com/LeoCostta/stepgate
6
+ Project-URL: Repository, https://github.com/LeoCostta/stepgate
7
+ Project-URL: Issues, https://github.com/LeoCostta/stepgate/issues
8
+ Author: Leo Costta
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: claude-code,cli,codex,coding-agents,guardrails,workflow
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Software Development :: Quality Assurance
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: filelock>=3.12
25
+ Requires-Dist: rich>=13.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8.0; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # stepgate
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/stepgate)](https://pypi.org/project/stepgate/)
33
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://pypi.org/project/stepgate/)
34
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
35
+
36
+ **A step-gated micro-change protocol for coding agents.**
37
+
38
+ Coding agents (Claude Code, Codex, …) working on large or loosely-scoped tasks
39
+ tend to mix contexts, silently expand scope, touch files unrelated to the
40
+ original request, or declare success without real evidence. `stepgate` makes
41
+ the correct workflow — investigate, propose, wait for approval, execute only
42
+ what was approved, verify, suggest the next step — **the single natural path
43
+ of action**, so that deviating from it is visible and recorded, never silent.
44
+
45
+ `stepgate` is *not* an enforcement tool. It never blocks your code, your
46
+ editor, your commits, or git. You can always edit anything, commit anything,
47
+ and cancel any agent session at any time. What it gives you is structure and
48
+ an honest, append-only trail of what was proposed, approved, executed, and
49
+ verified — across every agent working on the repo, even concurrently.
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pipx install stepgate # recommended: one global install per machine
55
+ # or: pip install stepgate
56
+ ```
57
+
58
+ Then, at the root of any project:
59
+
60
+ ```bash
61
+ stepgate init
62
+ ```
63
+
64
+ This creates `.stepgate/` (state + append-only history) and injects an
65
+ instruction block into `AGENTS.md`/`CLAUDE.md` telling agents to use the
66
+ protocol. It is idempotent and never overwrites anything you wrote in those
67
+ files — only the text between its own markers is ever touched. If your
68
+ project already has a domain guardrails document, point to it with
69
+ `--guardrails GUARDRAILS.md` and the generated block will reference it.
70
+
71
+ ## The protocol
72
+
73
+ Every micro-change is a proposal moving through a state machine, validated by
74
+ the CLI itself:
75
+
76
+ ```
77
+ PENDING ──approve──► APPROVED ──exec-log──► EXECUTED ──verify──► VERIFIED ──close──► CLOSED
78
+
79
+ └─reject──► REJECTED (any non-terminal state) ──abandon──► ABANDONED
80
+ ```
81
+
82
+ A proposal covers six points, each written as natural flowing prose (not a
83
+ telegraphic form): **what** will change now, **why** this step comes first,
84
+ **where** (files/contracts/flows touched), **how** it will be implemented,
85
+ the **expected result**, and the **verification** that will demonstrate it.
86
+
87
+ Key rule: *a micro-change reduces the scope of execution, never the depth of
88
+ investigation* — the agent still investigates everything it needs to
89
+ understand before proposing, even if it will only implement a small piece.
90
+
91
+ ## A real cycle
92
+
93
+ You ask your agent to fix a race condition. It investigates, then proposes:
94
+
95
+ ```bash
96
+ stepgate propose --agent claude --file plan.json
97
+ ```
98
+
99
+ You read it rendered as prose, and approve with a tweak:
100
+
101
+ ```bash
102
+ stepgate show
103
+ stepgate approve --adjust --note "rename the function to apply_sanity_loss_atomic"
104
+ ```
105
+
106
+ The agent implements **only** what was approved, logs it (a `git diff --stat`
107
+ is captured automatically as objective evidence), and verifies:
108
+
109
+ ```bash
110
+ stepgate exec-log --summary "atomic decrement migration + typing" --files "migrations/013.sql,types.ts"
111
+ stepgate verify --evidence "type-check ok, simulated concurrency test passed"
112
+ ```
113
+
114
+ You close the cycle; the agent suggests — but does not start — the next step:
115
+
116
+ ```bash
117
+ stepgate close
118
+ stepgate next --suggest "wire SalaJogo.tsx to the new function via supabase.rpc(...)"
119
+ ```
120
+
121
+ The suggestion stays visible in `stepgate status` until a new proposal is
122
+ opened. The full trail lives in `stepgate history` — chronological, across
123
+ all sessions and agents, append-only.
124
+
125
+ ## Commands
126
+
127
+ | Command | Purpose |
128
+ | --- | --- |
129
+ | `stepgate init` | Create `.stepgate/`, inject the agent instruction block |
130
+ | `stepgate propose --agent X --file plan.json` | Register a micro-change plan (PENDING) |
131
+ | `stepgate show` | Render the active proposal as readable prose |
132
+ | `stepgate approve [--adjust --scope ... --note ...]` | Approve (optionally with reduced scope) |
133
+ | `stepgate reject --note "..."` | Reject a pending proposal |
134
+ | `stepgate exec-log --summary "..." --files "..."` | Record execution (+ automatic `git diff --stat`) |
135
+ | `stepgate verify --evidence "..."` | Record verification evidence |
136
+ | `stepgate close` | Close a verified micro-change |
137
+ | `stepgate abandon --reason "..."` | Cleanly abandon from any non-terminal state |
138
+ | `stepgate next --suggest "..."` | Record a next-step suggestion (opens nothing) |
139
+ | `stepgate status` | Current session + aggregated project view |
140
+ | `stepgate history [--session X] [--since DATE]` | Append-only, cross-session log |
141
+ | `stepgate doctor` | Report corrupted/invalid state files (fixes nothing) |
142
+
143
+ Multiple agents can work concurrently: each session has its own state file
144
+ (`.stepgate/sessions/claude-2026-07-09-1.json` — human-readable names, not
145
+ hashes), writes are file-locked, and `propose`/`status` warn — informationally,
146
+ never blockingly — when two active proposals touch the same files.
147
+
148
+ ## Design principles
149
+
150
+ - **Never block.** No git hooks, no file locks on your code, no commit gates.
151
+ The file lock in `.stepgate/` protects only stepgate's own state files.
152
+ - **Never act silently.** No auto-repair, no auto-expiry of stale sessions,
153
+ no rewriting of history. `doctor` diagnoses; a human decides.
154
+ - **Non-interactive by design.** No prompts or confirmations, so it behaves
155
+ identically in a terminal, a desktop app, or an IDE side-panel extension.
156
+ - **Structure when you opt in.** Editing code without a proposal is a
157
+ legitimate flow, not an error. The state machine gives shape to the agent
158
+ workflow — it is never a requirement for the code to change.
159
+
160
+ ## License
161
+
162
+ [MIT](LICENSE) © Leo Costta
@@ -0,0 +1,16 @@
1
+ stepgate/__init__.py,sha256=IZxXfQ2n8pnmLgv6dj1Be9njsxplcENXp806poUTp8I,97
2
+ stepgate/__main__.py,sha256=Xyfrw2Y79AkRDE3dKx8uIVu4s-u2gzrfbzwkcBOFkTg,87
3
+ stepgate/cli.py,sha256=BzEWRXwl9grVouYrQW-zTsNlhAL6fU93Jl4MWynXITA,5102
4
+ stepgate/model.py,sha256=tJ7alq87B9fU6HIFeCCovUKu31syCMVvp5wCjqqCuMw,6153
5
+ stepgate/render.py,sha256=IGmyZ3maUy43NK3tDkvFOS0y1BQjc5-ZWAvg7PLLQRA,2702
6
+ stepgate/store.py,sha256=AIgdN7JoHYPHgvgVwJgmfaSkdVjPZqut-yxStq9FpyY,6451
7
+ stepgate/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ stepgate/commands/doctor.py,sha256=k4HdpoCO7VI8W2QeCKaxFzHuqkOlySY_XkJ51ffS_pc,2429
9
+ stepgate/commands/init_cmd.py,sha256=qbf2b-MXf3tYatAqLvXNuW33xWLNnrrMMEfsj_kQ-kE,5564
10
+ stepgate/commands/lifecycle.py,sha256=7kQehaK8BSa7CIatAyNnaxp-dkHKlUE1f9ziOuf0CHk,8119
11
+ stepgate/commands/views.py,sha256=M8BNp4HtmF3y9vZCq0kR3YiOzKpz-xRVkg3Ol5Kx2qM,4749
12
+ stepgate-0.1.0.dist-info/METADATA,sha256=pZO6YqvK1Wr0iBQWnKH3YK5hi6igi7eMdcGK8OCuJhc,7055
13
+ stepgate-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
14
+ stepgate-0.1.0.dist-info/entry_points.txt,sha256=s0w8mFOv3Zp6rDu3_xwZll-RP7QeklkzYuvl88_fKEc,47
15
+ stepgate-0.1.0.dist-info/licenses/LICENSE,sha256=VlYH_evnP_W3jmP7TtpaTmQQC0_cGvVK8F4AC9kd7ZI,1067
16
+ stepgate-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stepgate = stepgate.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo Costta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.