assertion-cli 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.
- api.py +211 -0
- assertion_cli-0.1.0.dist-info/METADATA +48 -0
- assertion_cli-0.1.0.dist-info/RECORD +15 -0
- assertion_cli-0.1.0.dist-info/WHEEL +5 -0
- assertion_cli-0.1.0.dist-info/entry_points.txt +2 -0
- assertion_cli-0.1.0.dist-info/top_level.txt +8 -0
- assertion_cli_templates/ACTIVATION.md +14 -0
- assertion_cli_templates/SKILL.md +177 -0
- assertion_cli_templates/__init__.py +0 -0
- bundle.py +26 -0
- git.py +189 -0
- link.py +21 -0
- main.py +499 -0
- models.py +86 -0
- session.py +220 -0
session.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic import ValidationError
|
|
4
|
+
|
|
5
|
+
from api import AssertionClient
|
|
6
|
+
from git import (
|
|
7
|
+
exit_with_error,
|
|
8
|
+
find_git_root,
|
|
9
|
+
get_origin_github_repo,
|
|
10
|
+
require_head_pushed,
|
|
11
|
+
)
|
|
12
|
+
from models import MetadataPayload, SessionContext, render_stack_list
|
|
13
|
+
|
|
14
|
+
ASSERTION_DIR_NAME = ".assertion"
|
|
15
|
+
PROMPTS_FILE_NAME = "prompts"
|
|
16
|
+
CHECKPOINT_FILE_NAME = "checkpoint"
|
|
17
|
+
METADATA_FILE_NAME = "metadata.json"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _require_repo_ready(start_path: Path) -> Path:
|
|
21
|
+
repo_root = find_git_root(start_path)
|
|
22
|
+
require_head_pushed(repo_root)
|
|
23
|
+
return repo_root
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_metadata(metadata_path: Path) -> MetadataPayload | None:
|
|
27
|
+
if not metadata_path.exists():
|
|
28
|
+
return None
|
|
29
|
+
try:
|
|
30
|
+
return MetadataPayload.model_validate_json(
|
|
31
|
+
metadata_path.read_text(encoding="utf-8")
|
|
32
|
+
)
|
|
33
|
+
except (ValidationError, ValueError) as exc:
|
|
34
|
+
exit_with_error(
|
|
35
|
+
f"Corrupt {metadata_path.name}: {exc}. "
|
|
36
|
+
"Delete .assertion/ and start a new session."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_context(repo_root: Path) -> SessionContext:
|
|
41
|
+
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
42
|
+
return SessionContext(
|
|
43
|
+
repo_root=repo_root,
|
|
44
|
+
assertion_dir=assertion_dir,
|
|
45
|
+
prompts_path=assertion_dir / PROMPTS_FILE_NAME,
|
|
46
|
+
checkpoint_path=assertion_dir / CHECKPOINT_FILE_NAME,
|
|
47
|
+
metadata_path=assertion_dir / METADATA_FILE_NAME,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _create_session(
|
|
52
|
+
repo_root: Path,
|
|
53
|
+
session_id: str,
|
|
54
|
+
stack_id: str,
|
|
55
|
+
) -> SessionContext:
|
|
56
|
+
ctx = _build_context(repo_root)
|
|
57
|
+
ctx.assertion_dir.mkdir(exist_ok=True)
|
|
58
|
+
|
|
59
|
+
# Don't touch the prompts file — the agent appends to it via `asrt prompt`.
|
|
60
|
+
ctx.checkpoint_path.write_text("", encoding="utf-8")
|
|
61
|
+
ctx.metadata_path.write_text(
|
|
62
|
+
MetadataPayload(
|
|
63
|
+
session_id=session_id,
|
|
64
|
+
stack_id=stack_id,
|
|
65
|
+
).model_dump_json(indent=2)
|
|
66
|
+
+ "\n",
|
|
67
|
+
encoding="utf-8",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return ctx
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _require_prompts_file(ctx: SessionContext) -> None:
|
|
74
|
+
if (
|
|
75
|
+
not ctx.prompts_path.exists()
|
|
76
|
+
or not ctx.prompts_path.read_text(encoding="utf-8").strip()
|
|
77
|
+
):
|
|
78
|
+
exit_with_error(
|
|
79
|
+
f"ERROR: {ASSERTION_DIR_NAME}/{PROMPTS_FILE_NAME} is missing or empty.\n"
|
|
80
|
+
'Record at least one customer prompt with `asrt prompt "<message>"` before running checkpoint.'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _stacks_hint() -> str:
|
|
85
|
+
"""Fetch available stacks and return a formatted hint for error messages."""
|
|
86
|
+
try:
|
|
87
|
+
client = AssertionClient()
|
|
88
|
+
stacks = client.stacks()
|
|
89
|
+
return (
|
|
90
|
+
"\n\nAvailable stacks:\n"
|
|
91
|
+
+ render_stack_list(stacks)
|
|
92
|
+
+ "\n\nTo start a new session:\n"
|
|
93
|
+
' asrt checkpoint --stack <STACK_ID> "<progress message>"'
|
|
94
|
+
)
|
|
95
|
+
except SystemExit:
|
|
96
|
+
return (
|
|
97
|
+
"\n\nRun `asrt stacks` to see available stack IDs, then:\n"
|
|
98
|
+
' asrt checkpoint --stack <STACK_ID> "<progress message>"'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_stack_id_for_repo(repo_root: Path) -> str:
|
|
103
|
+
"""Pick the stack whose `repo` field matches the current repo's origin.
|
|
104
|
+
|
|
105
|
+
Exits with a clear error if zero or more than one stack matches. The
|
|
106
|
+
enforcement is here (in the CLI) so the coding agent does not have to
|
|
107
|
+
follow a compliance rule — the rule is mechanical.
|
|
108
|
+
"""
|
|
109
|
+
owner_name = get_origin_github_repo(repo_root)
|
|
110
|
+
if owner_name is None:
|
|
111
|
+
exit_with_error(
|
|
112
|
+
"Could not determine this repo's GitHub `owner/name` from the `origin` "
|
|
113
|
+
"remote. Pass `--stack <id>` explicitly to override."
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
client = AssertionClient()
|
|
117
|
+
stacks = client.stacks()
|
|
118
|
+
|
|
119
|
+
if not stacks:
|
|
120
|
+
exit_with_error(
|
|
121
|
+
"No verification stacks exist in this workspace yet. Open the "
|
|
122
|
+
"Assertion web app, create a stack, and attach this repo "
|
|
123
|
+
f"(`{owner_name}`) to it. Then re-run the command."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
matches = [s for s in stacks if s.repo.strip() == owner_name]
|
|
127
|
+
|
|
128
|
+
if len(matches) == 1:
|
|
129
|
+
return matches[0].id
|
|
130
|
+
if len(matches) == 0:
|
|
131
|
+
bound_repos = sorted({s.repo.strip() for s in stacks})
|
|
132
|
+
exit_with_error(
|
|
133
|
+
f"No verification stack is attached to this repo (`{owner_name}`)."
|
|
134
|
+
f" Other stacks in this workspace are attached to: {', '.join(bound_repos)}."
|
|
135
|
+
" Open the Assertion web app, choose a stack,"
|
|
136
|
+
" and attach this repo to it. Then re-run the command."
|
|
137
|
+
)
|
|
138
|
+
# More than one match — let the agent / user pick explicitly.
|
|
139
|
+
names = ", ".join(f"{s.name} ({s.id})" for s in matches)
|
|
140
|
+
exit_with_error(
|
|
141
|
+
f"Multiple stacks are attached to `{owner_name}`: {names}. "
|
|
142
|
+
"Pass `--stack <id>` to disambiguate."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def start_new_session(
|
|
147
|
+
*,
|
|
148
|
+
start_path: Path,
|
|
149
|
+
stack_id: str,
|
|
150
|
+
) -> tuple[SessionContext, MetadataPayload]:
|
|
151
|
+
"""Create a new verification session with --stack."""
|
|
152
|
+
repo_root = _require_repo_ready(start_path)
|
|
153
|
+
|
|
154
|
+
client = AssertionClient()
|
|
155
|
+
init_resp = client.init()
|
|
156
|
+
|
|
157
|
+
valid_ids = {s.id for s in init_resp.stacks}
|
|
158
|
+
if stack_id not in valid_ids:
|
|
159
|
+
exit_with_error(f"ERROR: Unknown stack '{stack_id}'." + _stacks_hint())
|
|
160
|
+
|
|
161
|
+
ctx = _create_session(
|
|
162
|
+
repo_root,
|
|
163
|
+
session_id=init_resp.session_id,
|
|
164
|
+
stack_id=stack_id,
|
|
165
|
+
)
|
|
166
|
+
_require_prompts_file(ctx)
|
|
167
|
+
metadata = _load_metadata(ctx.metadata_path)
|
|
168
|
+
assert metadata is not None
|
|
169
|
+
return ctx, metadata
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def continue_session(
|
|
173
|
+
*,
|
|
174
|
+
start_path: Path,
|
|
175
|
+
) -> tuple[SessionContext, MetadataPayload]:
|
|
176
|
+
"""Continue an existing session with --continue."""
|
|
177
|
+
repo_root = _require_repo_ready(start_path)
|
|
178
|
+
ctx = _build_context(repo_root)
|
|
179
|
+
|
|
180
|
+
existing = _load_metadata(ctx.metadata_path)
|
|
181
|
+
if existing is None:
|
|
182
|
+
exit_with_error("ERROR: No active session to continue." + _stacks_hint())
|
|
183
|
+
|
|
184
|
+
_require_prompts_file(ctx)
|
|
185
|
+
return ctx, existing
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def load_existing_session(start_path: Path) -> tuple[SessionContext, MetadataPayload]:
|
|
189
|
+
"""Load an existing session or fail. Used by verify."""
|
|
190
|
+
repo_root = _require_repo_ready(start_path)
|
|
191
|
+
ctx = _build_context(repo_root)
|
|
192
|
+
|
|
193
|
+
existing = _load_metadata(ctx.metadata_path)
|
|
194
|
+
if existing is None:
|
|
195
|
+
exit_with_error(
|
|
196
|
+
"ERROR: No active session. Run a checkpoint first." + _stacks_hint()
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return ctx, existing
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def append_checkpoint_entry(checkpoint_path: Path, message: str) -> None:
|
|
203
|
+
normalized = message.rstrip("\n")
|
|
204
|
+
if not normalized.strip():
|
|
205
|
+
exit_with_error("Checkpoint message must not be empty.")
|
|
206
|
+
with checkpoint_path.open("a", encoding="utf-8") as f:
|
|
207
|
+
f.write(f"{normalized}\n\n")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def update_metadata_head(
|
|
211
|
+
metadata_path: Path,
|
|
212
|
+
metadata: MetadataPayload,
|
|
213
|
+
head_sha: str,
|
|
214
|
+
head_branch: str | None = None,
|
|
215
|
+
) -> MetadataPayload:
|
|
216
|
+
updated = metadata.model_copy(
|
|
217
|
+
update={"head_sha": head_sha, "head_branch": head_branch}
|
|
218
|
+
)
|
|
219
|
+
metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
220
|
+
return updated
|