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 +3 -0
- stepgate/__main__.py +4 -0
- stepgate/cli.py +126 -0
- stepgate/commands/__init__.py +0 -0
- stepgate/commands/doctor.py +62 -0
- stepgate/commands/init_cmd.py +132 -0
- stepgate/commands/lifecycle.py +238 -0
- stepgate/commands/views.py +129 -0
- stepgate/model.py +188 -0
- stepgate/render.py +83 -0
- stepgate/store.py +166 -0
- stepgate-0.1.0.dist-info/METADATA +162 -0
- stepgate-0.1.0.dist-info/RECORD +16 -0
- stepgate-0.1.0.dist-info/WHEEL +4 -0
- stepgate-0.1.0.dist-info/entry_points.txt +2 -0
- stepgate-0.1.0.dist-info/licenses/LICENSE +21 -0
stepgate/__init__.py
ADDED
stepgate/__main__.py
ADDED
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
|
+
[](https://pypi.org/project/stepgate/)
|
|
33
|
+
[](https://pypi.org/project/stepgate/)
|
|
34
|
+
[](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,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.
|