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
main.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import importlib.resources
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from api import AssertionClient
|
|
13
|
+
from bundle import build_bundle
|
|
14
|
+
from git import (
|
|
15
|
+
exit_with_error,
|
|
16
|
+
find_git_root,
|
|
17
|
+
get_head_branch,
|
|
18
|
+
get_head_sha,
|
|
19
|
+
get_uncommitted_diff,
|
|
20
|
+
)
|
|
21
|
+
from link import load_link, save_link
|
|
22
|
+
from models import CheckpointResponse, SessionContext, SessionStatus, render_stack_list
|
|
23
|
+
from session import (
|
|
24
|
+
ASSERTION_DIR_NAME,
|
|
25
|
+
METADATA_FILE_NAME,
|
|
26
|
+
PROMPTS_FILE_NAME,
|
|
27
|
+
append_checkpoint_entry,
|
|
28
|
+
continue_session,
|
|
29
|
+
load_existing_session,
|
|
30
|
+
start_new_session,
|
|
31
|
+
update_metadata_head,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
app = typer.Typer(help="Assertion CLI")
|
|
35
|
+
|
|
36
|
+
STATUS_POLL_INTERVAL_SECONDS = 2.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_checkpoint_response(resp: CheckpointResponse) -> str:
|
|
40
|
+
lines = [
|
|
41
|
+
f"checkpoint_id: {resp.checkpoint_id}",
|
|
42
|
+
f"outcome: {resp.outcome}",
|
|
43
|
+
]
|
|
44
|
+
for review in resp.reviews:
|
|
45
|
+
lines.append("")
|
|
46
|
+
lines.append(f"model: {review.model}")
|
|
47
|
+
lines.append(f"approved: {str(review.approved).lower()}")
|
|
48
|
+
lines.append(review.feedback)
|
|
49
|
+
|
|
50
|
+
lines.append("")
|
|
51
|
+
lines.append(resp.reasoning)
|
|
52
|
+
return "\n".join(lines)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback(invoke_without_command=True)
|
|
56
|
+
def main(ctx: typer.Context) -> None:
|
|
57
|
+
"""Assertion command group."""
|
|
58
|
+
if ctx.invoked_subcommand is None:
|
|
59
|
+
typer.echo(ctx.get_help())
|
|
60
|
+
raise typer.Exit(code=0)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("stacks")
|
|
64
|
+
def stacks() -> None:
|
|
65
|
+
"""List available verification stacks."""
|
|
66
|
+
client = AssertionClient()
|
|
67
|
+
stack_list = client.stacks()
|
|
68
|
+
typer.echo(render_stack_list(stack_list))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ASSERTION_MARKER_BEGIN = "<!-- assertion-cli:begin -->"
|
|
72
|
+
ASSERTION_MARKER_END = "<!-- assertion-cli:end -->"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_template(name: str) -> str:
|
|
76
|
+
return (
|
|
77
|
+
importlib.resources.files("assertion_cli_templates")
|
|
78
|
+
.joinpath(name)
|
|
79
|
+
.read_text(encoding="utf-8")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_full_file(path: Path, content: str, repo_root: Path) -> None:
|
|
84
|
+
"""Idempotently overwrite a file. Creates parent dirs as needed.
|
|
85
|
+
|
|
86
|
+
Silent on no-op; echoes only when a write actually happens.
|
|
87
|
+
"""
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
if path.exists() and path.read_text(encoding="utf-8") == content:
|
|
90
|
+
return
|
|
91
|
+
path.write_text(content, encoding="utf-8")
|
|
92
|
+
typer.echo(f"Wrote {path.relative_to(repo_root)}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _apply_marked_block(path: Path, body: str, repo_root: Path) -> None:
|
|
96
|
+
"""Idempotently insert/update an assertion-marked block in a markdown file.
|
|
97
|
+
|
|
98
|
+
Existing content outside the markers is preserved exactly.
|
|
99
|
+
Silent on no-op; echoes only when a write actually happens.
|
|
100
|
+
"""
|
|
101
|
+
marked_block = f"{ASSERTION_MARKER_BEGIN}\n{body}\n{ASSERTION_MARKER_END}\n"
|
|
102
|
+
pattern = re.compile(
|
|
103
|
+
re.escape(ASSERTION_MARKER_BEGIN)
|
|
104
|
+
+ r".*?"
|
|
105
|
+
+ re.escape(ASSERTION_MARKER_END)
|
|
106
|
+
+ r"\n?",
|
|
107
|
+
re.DOTALL,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if path.exists():
|
|
111
|
+
current = path.read_text(encoding="utf-8")
|
|
112
|
+
if pattern.search(current):
|
|
113
|
+
new_content = pattern.sub(marked_block, current)
|
|
114
|
+
else:
|
|
115
|
+
sep = (
|
|
116
|
+
""
|
|
117
|
+
if current.endswith("\n\n")
|
|
118
|
+
else ("\n" if current.endswith("\n") else "\n\n")
|
|
119
|
+
)
|
|
120
|
+
new_content = current + sep + marked_block
|
|
121
|
+
if new_content != current:
|
|
122
|
+
path.write_text(new_content, encoding="utf-8")
|
|
123
|
+
typer.echo(f"Updated {path.relative_to(repo_root)}")
|
|
124
|
+
else:
|
|
125
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
path.write_text(marked_block, encoding="utf-8")
|
|
127
|
+
typer.echo(f"Wrote {path.relative_to(repo_root)}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _refresh_skill_files(repo_root: Path) -> None:
|
|
131
|
+
"""Write/refresh the assertion-owned skill files for each coding agent.
|
|
132
|
+
|
|
133
|
+
Safe to call on every checkpoint — these files are entirely ours; updating
|
|
134
|
+
them to match the installed CLI version cannot clobber customer content.
|
|
135
|
+
|
|
136
|
+
`.claude/skills/` — Claude Code
|
|
137
|
+
`.agents/skills/` — Codex + Cursor (Cursor also accepts .cursor/skills/)
|
|
138
|
+
"""
|
|
139
|
+
skill_body = _load_template("SKILL.md")
|
|
140
|
+
_write_full_file(
|
|
141
|
+
repo_root / ".claude" / "skills" / "assertion-cli" / "SKILL.md",
|
|
142
|
+
skill_body,
|
|
143
|
+
repo_root,
|
|
144
|
+
)
|
|
145
|
+
_write_full_file(
|
|
146
|
+
repo_root / ".agents" / "skills" / "assertion-cli" / "SKILL.md",
|
|
147
|
+
skill_body,
|
|
148
|
+
repo_root,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
_GITIGNORE_ASSERTION_ENTRY = ".assertion/"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _ensure_gitignore_excludes_assertion(repo_root: Path) -> None:
|
|
156
|
+
"""Append `.assertion/` to the repo's .gitignore if not already excluded.
|
|
157
|
+
|
|
158
|
+
`.assertion/` holds per-session state (prompts, checkpoint message,
|
|
159
|
+
metadata, screenshots) — purely local working data. Without this entry it
|
|
160
|
+
surfaces as untracked changes that `asrt checkpoint`'s diff collector
|
|
161
|
+
would otherwise bundle and send to the reviewers.
|
|
162
|
+
"""
|
|
163
|
+
gitignore = repo_root / ".gitignore"
|
|
164
|
+
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
165
|
+
if any(
|
|
166
|
+
line.strip() in {".assertion", ".assertion/"} for line in existing.splitlines()
|
|
167
|
+
):
|
|
168
|
+
return
|
|
169
|
+
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
170
|
+
gitignore.write_text(
|
|
171
|
+
existing + prefix + _GITIGNORE_ASSERTION_ENTRY + "\n", encoding="utf-8"
|
|
172
|
+
)
|
|
173
|
+
typer.echo(
|
|
174
|
+
f"Updated {gitignore.relative_to(repo_root)} (added {_GITIGNORE_ASSERTION_ENTRY})"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _apply_activation_blocks(repo_root: Path) -> None:
|
|
179
|
+
"""Insert/update the marked activation block in customer-owned CLAUDE.md / AGENTS.md.
|
|
180
|
+
|
|
181
|
+
Only called by `asrt init`. We deliberately do NOT call this on every checkpoint —
|
|
182
|
+
these files may be tracked in the customer's git, and silently dirtying them on
|
|
183
|
+
every checkpoint is hostile. The block is a stable pointer to the skill file, so
|
|
184
|
+
re-running on every CLI upgrade isn't necessary.
|
|
185
|
+
"""
|
|
186
|
+
activation_body = _load_template("ACTIVATION.md").strip()
|
|
187
|
+
_apply_marked_block(repo_root / "CLAUDE.md", activation_body, repo_root)
|
|
188
|
+
_apply_marked_block(repo_root / "AGENTS.md", activation_body, repo_root)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _clear_session_state(ctx: SessionContext) -> None:
|
|
192
|
+
"""Remove per-session files so the next prompt/checkpoint starts cleanly.
|
|
193
|
+
|
|
194
|
+
Leaves the `.assertion/` directory itself (and `link`, `screenshots/`) in
|
|
195
|
+
place so the customer can still retrieve the just-completed session's URL.
|
|
196
|
+
"""
|
|
197
|
+
for path in (ctx.prompts_path, ctx.checkpoint_path, ctx.metadata_path):
|
|
198
|
+
path.unlink(missing_ok=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.command("init")
|
|
202
|
+
def init() -> None:
|
|
203
|
+
"""One-time bootstrap: install the assertion-cli skill files into this repo.
|
|
204
|
+
|
|
205
|
+
Writes `.claude/skills/assertion-cli/SKILL.md`, `.agents/skills/assertion-cli/SKILL.md`,
|
|
206
|
+
and the marked activation block in `CLAUDE.md` and `AGENTS.md`. Run this once after
|
|
207
|
+
`uv tool install assertion-cli` so the coding agent (Claude Code, Codex, Cursor) loads
|
|
208
|
+
the skill and knows to call `asrt`. `asrt checkpoint` refreshes the skill files on each
|
|
209
|
+
run so they stay aligned with the installed CLI; `CLAUDE.md` / `AGENTS.md` are only
|
|
210
|
+
touched here, never by checkpoint, so they're safe to track in git.
|
|
211
|
+
"""
|
|
212
|
+
repo_root = find_git_root(Path.cwd())
|
|
213
|
+
_refresh_skill_files(repo_root)
|
|
214
|
+
_apply_activation_blocks(repo_root)
|
|
215
|
+
_ensure_gitignore_excludes_assertion(repo_root)
|
|
216
|
+
typer.echo("")
|
|
217
|
+
typer.echo("The coding agent will now follow the Assertion workflow:")
|
|
218
|
+
typer.echo(' - asrt prompt "<msg>" on every customer turn')
|
|
219
|
+
typer.echo(" - asrt checkpoint at trajectory-feedback moments")
|
|
220
|
+
typer.echo(" - asrt verify at completion, then PR")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.command("prompt")
|
|
224
|
+
def prompt_cmd(
|
|
225
|
+
text: str = typer.Argument(..., help="Customer prompt text to record."),
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Append a customer prompt to the session prompt history."""
|
|
228
|
+
normalized = text.strip()
|
|
229
|
+
if not normalized:
|
|
230
|
+
exit_with_error("Prompt text must not be empty.")
|
|
231
|
+
|
|
232
|
+
repo_root = find_git_root(Path.cwd())
|
|
233
|
+
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
234
|
+
assertion_dir.mkdir(exist_ok=True)
|
|
235
|
+
prompts_path = assertion_dir / PROMPTS_FILE_NAME
|
|
236
|
+
|
|
237
|
+
entry = {
|
|
238
|
+
"ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
239
|
+
"text": normalized,
|
|
240
|
+
}
|
|
241
|
+
with prompts_path.open("a", encoding="utf-8") as f:
|
|
242
|
+
f.write(json.dumps(entry) + "\n")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@app.command("checkpoint")
|
|
246
|
+
def checkpoint(
|
|
247
|
+
message: str = typer.Argument(..., help="Checkpoint progress message."),
|
|
248
|
+
stack: str | None = typer.Option(
|
|
249
|
+
None,
|
|
250
|
+
"--stack",
|
|
251
|
+
help="Force a new session against this stack ID. Overwrites any existing session.",
|
|
252
|
+
),
|
|
253
|
+
continue_session_flag: bool = typer.Option(
|
|
254
|
+
False,
|
|
255
|
+
"--continue",
|
|
256
|
+
help="Require an existing session to continue (fail if none exists).",
|
|
257
|
+
),
|
|
258
|
+
json_output: bool = typer.Option(
|
|
259
|
+
False, "--json", help="Print the checkpoint response as a single JSON line."
|
|
260
|
+
),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Record a checkpoint.
|
|
263
|
+
|
|
264
|
+
With no flag, the CLI auto-continues the existing session if one is present,
|
|
265
|
+
otherwise starts a new session against the stack attached to this repo's
|
|
266
|
+
GitHub origin. Use `--continue` to fail fast when no session exists, or
|
|
267
|
+
`--stack <id>` to force a brand-new session (destructive — replaces any
|
|
268
|
+
in-flight session).
|
|
269
|
+
"""
|
|
270
|
+
if stack and continue_session_flag:
|
|
271
|
+
typer.echo("ERROR: --stack and --continue are mutually exclusive.", err=True)
|
|
272
|
+
raise typer.Exit(code=1)
|
|
273
|
+
|
|
274
|
+
repo_root = find_git_root(Path.cwd())
|
|
275
|
+
|
|
276
|
+
# Refresh the assertion-owned skill files so the agent always loads the
|
|
277
|
+
# version that ships with the installed CLI. We deliberately do NOT touch
|
|
278
|
+
# CLAUDE.md / AGENTS.md here — those are customer-owned and only updated
|
|
279
|
+
# by `asrt init`.
|
|
280
|
+
_refresh_skill_files(repo_root)
|
|
281
|
+
|
|
282
|
+
existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
|
|
283
|
+
|
|
284
|
+
if continue_session_flag or (stack is None and existing_metadata_path.exists()):
|
|
285
|
+
# Either the caller asked to continue explicitly, OR they passed no
|
|
286
|
+
# flag and a session already exists (2nd+ checkpoint of the same
|
|
287
|
+
# session). In both cases continue rather than starting fresh — the
|
|
288
|
+
# alternative would silently destroy an in-flight session. To force a
|
|
289
|
+
# new session, pass --stack <id> or clear .assertion/ first (the
|
|
290
|
+
# post-verify prompt offers to do this).
|
|
291
|
+
ctx, metadata = continue_session(start_path=Path.cwd())
|
|
292
|
+
else:
|
|
293
|
+
from session import resolve_stack_id_for_repo
|
|
294
|
+
|
|
295
|
+
resolved_stack = stack or resolve_stack_id_for_repo(repo_root)
|
|
296
|
+
ctx, metadata = start_new_session(
|
|
297
|
+
start_path=Path.cwd(), stack_id=resolved_stack
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
stack_id = metadata.stack_id
|
|
301
|
+
assert stack_id is not None
|
|
302
|
+
|
|
303
|
+
append_checkpoint_entry(ctx.checkpoint_path, message)
|
|
304
|
+
head_sha = get_head_sha(ctx.repo_root)
|
|
305
|
+
head_branch = get_head_branch(ctx.repo_root)
|
|
306
|
+
diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
307
|
+
metadata = update_metadata_head(
|
|
308
|
+
ctx.metadata_path, metadata, head_sha, head_branch=head_branch
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
bundle_bytes = build_bundle(
|
|
312
|
+
metadata=metadata,
|
|
313
|
+
diff_text=diff_text,
|
|
314
|
+
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
315
|
+
checkpoint_text=message,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
client = AssertionClient()
|
|
319
|
+
resp = client.checkpoint(
|
|
320
|
+
stack_id=stack_id,
|
|
321
|
+
session_id=metadata.session_id,
|
|
322
|
+
bundle_bytes=bundle_bytes,
|
|
323
|
+
)
|
|
324
|
+
if json_output:
|
|
325
|
+
typer.echo(resp.model_dump_json())
|
|
326
|
+
else:
|
|
327
|
+
typer.echo(render_checkpoint_response(resp))
|
|
328
|
+
if str(resp.outcome).endswith("failed"):
|
|
329
|
+
raise typer.Exit(code=1)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@app.command("verify")
|
|
333
|
+
def verify(
|
|
334
|
+
json_output: bool = typer.Option(
|
|
335
|
+
False,
|
|
336
|
+
"--json",
|
|
337
|
+
help="Print the terminal verification status as a single JSON line.",
|
|
338
|
+
),
|
|
339
|
+
no_wait: bool = typer.Option(
|
|
340
|
+
False,
|
|
341
|
+
"--no-wait",
|
|
342
|
+
help="Submit and return immediately without polling. Useful for harnesses.",
|
|
343
|
+
),
|
|
344
|
+
) -> None:
|
|
345
|
+
"""Submit final verification and wait for the result."""
|
|
346
|
+
ctx, metadata = load_existing_session(Path.cwd())
|
|
347
|
+
stack_id = metadata.stack_id
|
|
348
|
+
assert stack_id is not None
|
|
349
|
+
head_sha = get_head_sha(ctx.repo_root)
|
|
350
|
+
head_branch = get_head_branch(ctx.repo_root)
|
|
351
|
+
diff_text = get_uncommitted_diff(ctx.repo_root)
|
|
352
|
+
metadata = update_metadata_head(
|
|
353
|
+
ctx.metadata_path, metadata, head_sha, head_branch=head_branch
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
bundle_bytes = build_bundle(
|
|
357
|
+
metadata=metadata,
|
|
358
|
+
diff_text=diff_text,
|
|
359
|
+
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
360
|
+
checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
client = AssertionClient()
|
|
364
|
+
resp = client.verify(
|
|
365
|
+
stack_id=stack_id,
|
|
366
|
+
session_id=metadata.session_id,
|
|
367
|
+
bundle_bytes=bundle_bytes,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
session_id = resp.session_id
|
|
371
|
+
session_url = resp.url
|
|
372
|
+
save_link(ctx.assertion_dir, session_url)
|
|
373
|
+
|
|
374
|
+
if no_wait:
|
|
375
|
+
if json_output:
|
|
376
|
+
typer.echo(
|
|
377
|
+
json.dumps(
|
|
378
|
+
{
|
|
379
|
+
"session_id": session_id,
|
|
380
|
+
"url": session_url,
|
|
381
|
+
"status": "submitted",
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
typer.echo(f"Verification submitted ({session_id}). URL: {session_url}")
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
if not json_output:
|
|
390
|
+
typer.echo(f"Verification submitted ({session_id}). Waiting for result...")
|
|
391
|
+
|
|
392
|
+
poll_count = 0
|
|
393
|
+
while True:
|
|
394
|
+
payload = client.status(session_id=session_id)
|
|
395
|
+
|
|
396
|
+
if payload.status in {SessionStatus.CREATED, SessionStatus.RUNNING}:
|
|
397
|
+
poll_count += 1
|
|
398
|
+
if not json_output:
|
|
399
|
+
sys.stderr.write(".")
|
|
400
|
+
sys.stderr.flush()
|
|
401
|
+
time.sleep(STATUS_POLL_INTERVAL_SECONDS)
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if poll_count > 0 and not json_output:
|
|
405
|
+
sys.stderr.write("\n")
|
|
406
|
+
sys.stderr.flush()
|
|
407
|
+
|
|
408
|
+
if payload.url:
|
|
409
|
+
session_url = payload.url
|
|
410
|
+
save_link(ctx.assertion_dir, session_url)
|
|
411
|
+
|
|
412
|
+
if payload.screenshots:
|
|
413
|
+
screenshot_dir = Path(".assertion") / "screenshots" / session_id
|
|
414
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
for idx, uri in enumerate(payload.screenshots):
|
|
416
|
+
_, b64data = uri.split(",", 1)
|
|
417
|
+
ext = "jpeg" if "jpeg" in uri.split(",")[0] else "png"
|
|
418
|
+
path = screenshot_dir / f"screenshot_{idx:03d}.{ext}"
|
|
419
|
+
path.write_bytes(base64.b64decode(b64data))
|
|
420
|
+
|
|
421
|
+
if json_output:
|
|
422
|
+
typer.echo(
|
|
423
|
+
json.dumps(
|
|
424
|
+
{
|
|
425
|
+
"session_id": session_id,
|
|
426
|
+
"url": session_url,
|
|
427
|
+
"status": str(payload.status),
|
|
428
|
+
"message": payload.message,
|
|
429
|
+
"summary": payload.summary,
|
|
430
|
+
"screenshot_count": len(payload.screenshots),
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
)
|
|
434
|
+
if payload.status == SessionStatus.FAILED:
|
|
435
|
+
raise typer.Exit(code=1)
|
|
436
|
+
return
|
|
437
|
+
|
|
438
|
+
if payload.message is None:
|
|
439
|
+
typer.echo(
|
|
440
|
+
"Invalid API response: terminal status must include a message.",
|
|
441
|
+
err=True,
|
|
442
|
+
)
|
|
443
|
+
raise typer.Exit(code=1)
|
|
444
|
+
|
|
445
|
+
typer.echo(payload.message)
|
|
446
|
+
if payload.summary:
|
|
447
|
+
typer.echo(f"\nSummary:\n{payload.summary}")
|
|
448
|
+
if payload.screenshots:
|
|
449
|
+
typer.echo(
|
|
450
|
+
f"\nScreenshots ({len(payload.screenshots)}) saved to "
|
|
451
|
+
f".assertion/screenshots/{session_id}/"
|
|
452
|
+
)
|
|
453
|
+
typer.echo(f"\nSession: {session_url}")
|
|
454
|
+
if payload.status == SessionStatus.FAILED:
|
|
455
|
+
raise typer.Exit(code=1)
|
|
456
|
+
|
|
457
|
+
# On a clean verify in an interactive shell, offer to clear session
|
|
458
|
+
# state so the next `asrt prompt` / `asrt checkpoint` starts fresh.
|
|
459
|
+
if sys.stdin.isatty() and typer.confirm(
|
|
460
|
+
"\nStart a new session? This clears .assertion/ (prompts, metadata, checkpoint).",
|
|
461
|
+
default=False,
|
|
462
|
+
):
|
|
463
|
+
_clear_session_state(ctx)
|
|
464
|
+
typer.echo("Cleared. Next `asrt prompt` starts a new session.")
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@app.command("get-link")
|
|
469
|
+
def get_link() -> None:
|
|
470
|
+
"""Print the session link for the current verification session."""
|
|
471
|
+
ctx, metadata = load_existing_session(Path.cwd())
|
|
472
|
+
url = load_link(ctx.assertion_dir)
|
|
473
|
+
typer.echo(url)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@app.command("decision")
|
|
477
|
+
def decision(
|
|
478
|
+
checkpoint_id: str = typer.Argument(..., help="Checkpoint ID."),
|
|
479
|
+
yes: bool = typer.Option(
|
|
480
|
+
False, "--yes", help="Agree with the checkpoint feedback."
|
|
481
|
+
),
|
|
482
|
+
no: bool = typer.Option(
|
|
483
|
+
False, "--no", help="Disagree with the checkpoint feedback."
|
|
484
|
+
),
|
|
485
|
+
) -> None:
|
|
486
|
+
"""Record an agree/disagree sentiment for a failed checkpoint."""
|
|
487
|
+
if yes == no:
|
|
488
|
+
typer.echo("ERROR: Pass exactly one of --yes or --no.", err=True)
|
|
489
|
+
raise typer.Exit(code=1)
|
|
490
|
+
|
|
491
|
+
client = AssertionClient()
|
|
492
|
+
client.decision(
|
|
493
|
+
checkpoint_id=checkpoint_id,
|
|
494
|
+
decision="yes" if yes else "no",
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
if __name__ == "__main__":
|
|
499
|
+
app()
|
models.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from enum import StrEnum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class VerificationStack(BaseModel):
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
description: str
|
|
13
|
+
repo: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InitResponse(BaseModel):
|
|
17
|
+
session_id: str
|
|
18
|
+
stacks: list[VerificationStack]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VerifyResponse(BaseModel):
|
|
22
|
+
session_id: str
|
|
23
|
+
url: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ReviewDetail(BaseModel):
|
|
27
|
+
model: str
|
|
28
|
+
approved: bool
|
|
29
|
+
feedback: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CheckpointResponse(BaseModel):
|
|
33
|
+
checkpoint_id: str
|
|
34
|
+
outcome: str
|
|
35
|
+
reasoning: str
|
|
36
|
+
reviews: list[ReviewDetail]
|
|
37
|
+
created_at: str
|
|
38
|
+
updated_at: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DecisionResponse(BaseModel):
|
|
42
|
+
ok: bool = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionStatus(StrEnum):
|
|
46
|
+
CREATED = "created"
|
|
47
|
+
RUNNING = "running"
|
|
48
|
+
SUCCEEDED = "succeeded"
|
|
49
|
+
FAILED = "failed"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class StatusResponse(BaseModel):
|
|
53
|
+
status: SessionStatus
|
|
54
|
+
message: str | None = None
|
|
55
|
+
summary: str | None = None
|
|
56
|
+
screenshots: list[str] = []
|
|
57
|
+
url: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ErrorResponse(BaseModel):
|
|
61
|
+
error: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MetadataPayload(BaseModel):
|
|
65
|
+
model_config = ConfigDict(extra="allow")
|
|
66
|
+
|
|
67
|
+
session_id: str
|
|
68
|
+
stack_id: str | None = None
|
|
69
|
+
head_sha: str | None = None
|
|
70
|
+
head_branch: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_stack_list(stacks: Sequence["VerificationStack"]) -> str:
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
for s in stacks:
|
|
76
|
+
lines.append(f"- {s.id}: {s.name} [repo: {s.repo}] — {s.description}")
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SessionContext:
|
|
82
|
+
repo_root: Path
|
|
83
|
+
assertion_dir: Path
|
|
84
|
+
prompts_path: Path
|
|
85
|
+
checkpoint_path: Path
|
|
86
|
+
metadata_path: Path
|