assertion-cli 0.2.0__tar.gz → 0.4.0__tar.gz
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.
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/PKG-INFO +1 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/PKG-INFO +1 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/SOURCES.txt +1 -2
- assertion_cli-0.4.0/bundle.py +42 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/git.py +0 -101
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/main.py +94 -37
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/models.py +12 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/pyproject.toml +1 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/session.py +34 -52
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/ACTIVATION.md +1 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/SKILL.md +37 -46
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_bundle.py +5 -1
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_git.py +9 -88
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_init.py +56 -0
- assertion_cli-0.4.0/tests/test_main.py +82 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_session.py +64 -8
- assertion_cli-0.2.0/bundle.py +0 -26
- assertion_cli-0.2.0/tests/test_main.py +0 -23
- assertion_cli-0.2.0/tests/test_stack_resolve.py +0 -120
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/README.md +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/api.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/link.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/setup.cfg +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/__init__.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_api.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_decision.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_link.py +0 -0
- {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_prompt.py +0 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import zipfile
|
|
4
|
+
|
|
5
|
+
from models import MetadataPayload
|
|
6
|
+
|
|
7
|
+
ASSERTION_DIR_NAME = ".assertion"
|
|
8
|
+
DIFF_ARCHIVE_PATH = "git.diff"
|
|
9
|
+
METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
|
|
10
|
+
PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
|
|
11
|
+
CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _metadata_wire_json(metadata: MetadataPayload) -> str:
|
|
15
|
+
"""Serialize metadata for the bundle, remapping `base_sha` → `head_sha`.
|
|
16
|
+
|
|
17
|
+
The CLI stores the diff base as `base_sha` because that matches what the
|
|
18
|
+
field means from the CLI's perspective (the foundation the diff builds
|
|
19
|
+
on). The backend reads it as `head_sha` because after its `git checkout`
|
|
20
|
+
that SHA literally is the clone's HEAD. Same value, different name per
|
|
21
|
+
side; the remap lives here so neither side has to know about the other's
|
|
22
|
+
perspective.
|
|
23
|
+
"""
|
|
24
|
+
payload = metadata.model_dump()
|
|
25
|
+
payload["head_sha"] = payload.pop("base_sha", None)
|
|
26
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_bundle(
|
|
30
|
+
*,
|
|
31
|
+
metadata: MetadataPayload,
|
|
32
|
+
diff_text: str,
|
|
33
|
+
prompts_text: str,
|
|
34
|
+
checkpoint_text: str,
|
|
35
|
+
) -> bytes:
|
|
36
|
+
buf = io.BytesIO()
|
|
37
|
+
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
38
|
+
zf.writestr(METADATA_ARCHIVE_PATH, _metadata_wire_json(metadata))
|
|
39
|
+
zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
|
|
40
|
+
zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
|
|
41
|
+
zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
|
|
42
|
+
return buf.getvalue()
|
|
@@ -37,73 +37,6 @@ def find_git_root(start_path: Path) -> Path:
|
|
|
37
37
|
return Path(completed.stdout.strip())
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def get_diff_base_sha(repo_root: Path) -> str:
|
|
41
|
-
"""Return the SHA the verifier should diff against — i.e. the most recent
|
|
42
|
-
commit reachable from BOTH local HEAD and some `refs/remotes/*` ref.
|
|
43
|
-
|
|
44
|
-
The verifier clones from GitHub and runs `git checkout <head_sha>` (see
|
|
45
|
-
backend/repo_analysis/clone.py). That checkout fails for any commit that
|
|
46
|
-
only exists locally — which is exactly what Codex Cloud produces when its
|
|
47
|
-
agent autocommits before `asrt checkpoint`. By picking the latest commit
|
|
48
|
-
that's on origin AND reachable from HEAD, the checkout always succeeds;
|
|
49
|
-
any local-only commits on top of that base flow through as part of the
|
|
50
|
-
diff (see `get_uncommitted_diff`), so the verifier still sees the full
|
|
51
|
-
state the agent built.
|
|
52
|
-
|
|
53
|
-
When HEAD itself is on a remote-tracking ref (no local commits),
|
|
54
|
-
`git merge-base HEAD <ref>` returns HEAD, so this collapses to the old
|
|
55
|
-
behavior with no diff growth.
|
|
56
|
-
"""
|
|
57
|
-
try:
|
|
58
|
-
remote_refs = run_git_command(
|
|
59
|
-
repo_root, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]
|
|
60
|
-
)
|
|
61
|
-
except RuntimeError as exc:
|
|
62
|
-
exit_with_error(f"Failed to inspect remote refs: {exc}")
|
|
63
|
-
|
|
64
|
-
refs = [
|
|
65
|
-
ref for ref in remote_refs.splitlines() if ref and not ref.endswith("/HEAD")
|
|
66
|
-
]
|
|
67
|
-
if not refs:
|
|
68
|
-
exit_with_error(
|
|
69
|
-
"No remote-tracking branches found. The verifier needs a commit "
|
|
70
|
-
"on origin to apply the diff onto — push at least one branch "
|
|
71
|
-
"(and `git fetch`) before running this command."
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
candidate_shas: list[str] = []
|
|
75
|
-
for ref in refs:
|
|
76
|
-
completed = subprocess.run(
|
|
77
|
-
["git", "merge-base", "HEAD", ref],
|
|
78
|
-
cwd=repo_root,
|
|
79
|
-
capture_output=True,
|
|
80
|
-
text=True,
|
|
81
|
-
check=False,
|
|
82
|
-
)
|
|
83
|
-
if completed.returncode == 0:
|
|
84
|
-
sha = completed.stdout.strip()
|
|
85
|
-
if sha:
|
|
86
|
-
candidate_shas.append(sha)
|
|
87
|
-
|
|
88
|
-
if not candidate_shas:
|
|
89
|
-
exit_with_error(
|
|
90
|
-
"HEAD has no commits in common with any remote-tracking branch. "
|
|
91
|
-
"Push the branch (or its parent commits) to origin and retry."
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
best_sha = candidate_shas[0]
|
|
95
|
-
best_ts = -1
|
|
96
|
-
for sha in candidate_shas:
|
|
97
|
-
try:
|
|
98
|
-
ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", sha]))
|
|
99
|
-
except (RuntimeError, ValueError):
|
|
100
|
-
continue
|
|
101
|
-
if ts > best_ts:
|
|
102
|
-
best_ts = ts
|
|
103
|
-
best_sha = sha
|
|
104
|
-
return best_sha
|
|
105
|
-
|
|
106
|
-
|
|
107
40
|
def get_head_sha(repo_root: Path) -> str:
|
|
108
41
|
try:
|
|
109
42
|
return run_git_command(repo_root, ["rev-parse", "HEAD"])
|
|
@@ -120,40 +53,6 @@ def get_head_branch(repo_root: Path) -> str | None:
|
|
|
120
53
|
return name if name and name != "HEAD" else None
|
|
121
54
|
|
|
122
55
|
|
|
123
|
-
def get_origin_github_repo(repo_root: Path) -> str | None:
|
|
124
|
-
"""Return the current repo's GitHub `owner/name` from origin, or None if not parseable.
|
|
125
|
-
|
|
126
|
-
Accepts the common remote URL forms:
|
|
127
|
-
git@github.com:owner/name(.git)?
|
|
128
|
-
https://github.com/owner/name(.git)?
|
|
129
|
-
ssh://git@github.com/owner/name(.git)?
|
|
130
|
-
"""
|
|
131
|
-
try:
|
|
132
|
-
url = run_git_command(repo_root, ["remote", "get-url", "origin"]).strip()
|
|
133
|
-
except RuntimeError:
|
|
134
|
-
return None
|
|
135
|
-
if not url:
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
if url.startswith("git@github.com:"):
|
|
139
|
-
path = url[len("git@github.com:") :]
|
|
140
|
-
elif url.startswith("ssh://git@github.com/"):
|
|
141
|
-
path = url[len("ssh://git@github.com/") :]
|
|
142
|
-
elif url.startswith("https://github.com/"):
|
|
143
|
-
path = url[len("https://github.com/") :]
|
|
144
|
-
elif url.startswith("http://github.com/"):
|
|
145
|
-
path = url[len("http://github.com/") :]
|
|
146
|
-
else:
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
path = path.rstrip("/")
|
|
150
|
-
if path.endswith(".git"):
|
|
151
|
-
path = path[: -len(".git")]
|
|
152
|
-
if path.count("/") != 1 or not all(path.split("/")):
|
|
153
|
-
return None
|
|
154
|
-
return path
|
|
155
|
-
|
|
156
|
-
|
|
157
56
|
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
158
57
|
completed = subprocess.run(
|
|
159
58
|
[
|
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import httpx
|
|
8
9
|
import typer
|
|
9
10
|
|
|
10
11
|
from api import AssertionClient
|
|
@@ -12,21 +13,23 @@ from bundle import build_bundle
|
|
|
12
13
|
from git import (
|
|
13
14
|
exit_with_error,
|
|
14
15
|
find_git_root,
|
|
15
|
-
get_diff_base_sha,
|
|
16
16
|
get_head_branch,
|
|
17
|
+
get_head_sha,
|
|
17
18
|
get_uncommitted_diff,
|
|
18
19
|
)
|
|
19
20
|
from link import load_link, save_link
|
|
20
21
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
21
22
|
from session import (
|
|
22
23
|
ASSERTION_DIR_NAME,
|
|
24
|
+
BASE_SHA_FILE_NAME,
|
|
23
25
|
METADATA_FILE_NAME,
|
|
24
26
|
PROMPTS_FILE_NAME,
|
|
25
27
|
append_checkpoint_entry,
|
|
26
28
|
continue_session,
|
|
27
29
|
load_existing_session,
|
|
30
|
+
read_init_base_sha,
|
|
28
31
|
start_new_session,
|
|
29
|
-
|
|
32
|
+
update_metadata_anchor,
|
|
30
33
|
)
|
|
31
34
|
|
|
32
35
|
app = typer.Typer(help="Assertion CLI")
|
|
@@ -194,11 +197,26 @@ def init() -> None:
|
|
|
194
197
|
the skill and knows to call `asrt`. `asrt checkpoint` refreshes the skill files on each
|
|
195
198
|
run so they stay aligned with the installed CLI; `CLAUDE.md` / `AGENTS.md` are only
|
|
196
199
|
touched here, never by checkpoint, so they're safe to track in git.
|
|
200
|
+
|
|
201
|
+
Also records the current HEAD SHA as the diff base for all future checkpoint/verify
|
|
202
|
+
calls in this repo. The base is what the backend `git checkout`s against before
|
|
203
|
+
applying the diff — capturing it once at init means later commands don't need to
|
|
204
|
+
rediscover it (which previously broke in sandboxes with no remote-tracking refs).
|
|
197
205
|
"""
|
|
198
206
|
repo_root = find_git_root(Path.cwd())
|
|
199
207
|
_refresh_skill_files(repo_root)
|
|
200
208
|
_apply_activation_blocks(repo_root)
|
|
201
209
|
_ensure_gitignore_excludes_assertion(repo_root)
|
|
210
|
+
|
|
211
|
+
base_sha = get_head_sha(repo_root)
|
|
212
|
+
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
213
|
+
assertion_dir.mkdir(exist_ok=True)
|
|
214
|
+
base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
|
|
215
|
+
base_sha_path.write_text(base_sha + "\n", encoding="utf-8")
|
|
216
|
+
typer.echo(
|
|
217
|
+
f"Recorded diff base {base_sha[:12]} → {base_sha_path.relative_to(repo_root)}"
|
|
218
|
+
)
|
|
219
|
+
|
|
202
220
|
typer.echo("")
|
|
203
221
|
typer.echo("The coding agent will now follow the Assertion workflow:")
|
|
204
222
|
typer.echo(' - asrt prompt "<msg>" on every user turn')
|
|
@@ -234,7 +252,7 @@ def checkpoint(
|
|
|
234
252
|
stack: str | None = typer.Option(
|
|
235
253
|
None,
|
|
236
254
|
"--stack",
|
|
237
|
-
help="
|
|
255
|
+
help="Stack ID for a new session. Required on the first checkpoint; rejected once a session exists.",
|
|
238
256
|
),
|
|
239
257
|
continue_session_flag: bool = typer.Option(
|
|
240
258
|
False,
|
|
@@ -247,11 +265,12 @@ def checkpoint(
|
|
|
247
265
|
) -> None:
|
|
248
266
|
"""Record a checkpoint.
|
|
249
267
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
First checkpoint of a session requires `--stack <id>` — the agent picks
|
|
269
|
+
the stack from `asrt stacks` using its own judgment (folder name, branch,
|
|
270
|
+
code, README, etc.). Once a session exists, the stack is locked: pass no
|
|
271
|
+
flag (auto-continues) or `--continue` (fails fast if no session exists).
|
|
272
|
+
Passing `--stack` on a later checkpoint is rejected — sessions cannot be
|
|
273
|
+
re-anchored mid-flight.
|
|
255
274
|
"""
|
|
256
275
|
if stack and continue_session_flag:
|
|
257
276
|
typer.echo("ERROR: --stack and --continue are mutually exclusive.", err=True)
|
|
@@ -266,35 +285,43 @@ def checkpoint(
|
|
|
266
285
|
_refresh_skill_files(repo_root)
|
|
267
286
|
|
|
268
287
|
existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
|
|
288
|
+
session_exists = existing_metadata_path.exists()
|
|
269
289
|
|
|
270
|
-
if
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
290
|
+
if stack and session_exists:
|
|
291
|
+
# The stack is locked once a session is anchored to it. The agent
|
|
292
|
+
# should never re-anchor — a wrong-stack mid-session creates a split
|
|
293
|
+
# verification record. The user is the only one who resets sessions.
|
|
294
|
+
exit_with_error(
|
|
295
|
+
"This session is already anchored to a stack. Sessions cannot be "
|
|
296
|
+
"re-anchored mid-flight — drop the `--stack` flag and re-run "
|
|
297
|
+
'`asrt checkpoint "<message>"` to continue. The anchored stack '
|
|
298
|
+
"is recorded in .assertion/metadata.json."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if continue_session_flag or session_exists:
|
|
277
302
|
ctx, metadata = continue_session(start_path=Path.cwd())
|
|
278
303
|
else:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
304
|
+
if stack is None:
|
|
305
|
+
exit_with_error(
|
|
306
|
+
"No session exists yet and no --stack given. Run `asrt stacks` "
|
|
307
|
+
"to see available stacks, pick one whose name/description fits "
|
|
308
|
+
"the work, then run:\n"
|
|
309
|
+
' asrt checkpoint --stack <STACK_ID> "<progress message>"'
|
|
310
|
+
)
|
|
311
|
+
ctx, metadata = start_new_session(start_path=Path.cwd(), stack_id=stack)
|
|
285
312
|
|
|
286
313
|
stack_id = metadata.stack_id
|
|
287
314
|
assert stack_id is not None
|
|
288
315
|
|
|
289
316
|
append_checkpoint_entry(ctx.checkpoint_path, message)
|
|
290
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
base_sha =
|
|
317
|
+
# Base SHA is whatever `asrt init` recorded for this repo. Backend uses
|
|
318
|
+
# it as the checkout target before applying the diff. Pinning it at init
|
|
319
|
+
# time (instead of rediscovering per command) means sandboxes without
|
|
320
|
+
# remote-tracking refs — e.g. Codex Cloud — still work.
|
|
321
|
+
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
295
322
|
head_branch = get_head_branch(ctx.repo_root)
|
|
296
323
|
diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
|
|
297
|
-
metadata =
|
|
324
|
+
metadata = update_metadata_anchor(
|
|
298
325
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
299
326
|
)
|
|
300
327
|
|
|
@@ -342,17 +369,34 @@ def _load_verify_session_id(assertion_dir: Path) -> str:
|
|
|
342
369
|
return content
|
|
343
370
|
|
|
344
371
|
|
|
372
|
+
def _decode_screenshot(uri: str) -> tuple[bytes, str]:
|
|
373
|
+
"""Resolve a screenshot reference to (image bytes, file extension).
|
|
374
|
+
|
|
375
|
+
The backend may hand back either an inline ``data:`` URI (legacy format)
|
|
376
|
+
or an http(s) download URL pointing at the files proxy (current format,
|
|
377
|
+
e.g. ``.../api/v0/files/download/<token>``). Support both so a format
|
|
378
|
+
change on the backend doesn't break status reporting.
|
|
379
|
+
"""
|
|
380
|
+
if uri.startswith("data:") and "," in uri:
|
|
381
|
+
header, b64data = uri.split(",", 1)
|
|
382
|
+
ext = "jpeg" if "jpeg" in header else "png"
|
|
383
|
+
return base64.b64decode(b64data), ext
|
|
384
|
+
|
|
385
|
+
response = httpx.get(uri, timeout=30.0, follow_redirects=True)
|
|
386
|
+
response.raise_for_status()
|
|
387
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
388
|
+
is_jpeg = "jpeg" in content_type or uri.lower().endswith((".jpg", ".jpeg"))
|
|
389
|
+
return response.content, "jpeg" if is_jpeg else "png"
|
|
390
|
+
|
|
391
|
+
|
|
345
392
|
def _write_screenshots(
|
|
346
393
|
assertion_dir: Path, session_id: str, screenshots: list[str]
|
|
347
394
|
) -> Path:
|
|
348
395
|
screenshot_dir = assertion_dir / "screenshots" / session_id
|
|
349
396
|
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
350
397
|
for idx, uri in enumerate(screenshots):
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
(screenshot_dir / f"screenshot_{idx:03d}.{ext}").write_bytes(
|
|
354
|
-
base64.b64decode(b64data)
|
|
355
|
-
)
|
|
398
|
+
data, ext = _decode_screenshot(uri)
|
|
399
|
+
(screenshot_dir / f"screenshot_{idx:03d}.{ext}").write_bytes(data)
|
|
356
400
|
return screenshot_dir
|
|
357
401
|
|
|
358
402
|
|
|
@@ -374,11 +418,12 @@ def verify(
|
|
|
374
418
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
375
419
|
stack_id = metadata.stack_id
|
|
376
420
|
assert stack_id is not None
|
|
377
|
-
# See checkpoint() for why
|
|
378
|
-
|
|
421
|
+
# See checkpoint() for why the base is the SHA `asrt init` recorded,
|
|
422
|
+
# not local HEAD.
|
|
423
|
+
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
379
424
|
head_branch = get_head_branch(ctx.repo_root)
|
|
380
425
|
diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
|
|
381
|
-
metadata =
|
|
426
|
+
metadata = update_metadata_anchor(
|
|
382
427
|
ctx.metadata_path, metadata, base_sha, head_branch=head_branch
|
|
383
428
|
)
|
|
384
429
|
|
|
@@ -451,8 +496,20 @@ def verify_status(
|
|
|
451
496
|
|
|
452
497
|
terminal = payload.status in {SessionStatus.SUCCEEDED, SessionStatus.FAILED}
|
|
453
498
|
screenshot_count = len(payload.screenshots)
|
|
499
|
+
screenshots_saved = False
|
|
454
500
|
if terminal and payload.screenshots:
|
|
455
|
-
|
|
501
|
+
# Saving screenshots must never block the verdict. A backend format
|
|
502
|
+
# change or a transient download failure should degrade to a warning,
|
|
503
|
+
# not swallow a `succeeded`/`failed` status the caller is waiting on.
|
|
504
|
+
try:
|
|
505
|
+
_write_screenshots(ctx.assertion_dir, session_id, payload.screenshots)
|
|
506
|
+
screenshots_saved = True
|
|
507
|
+
except Exception as exc: # noqa: BLE001 - never let screenshots hide the verdict
|
|
508
|
+
typer.echo(
|
|
509
|
+
f"warning: could not save screenshots ({exc}); "
|
|
510
|
+
"the verdict below is unaffected.",
|
|
511
|
+
err=True,
|
|
512
|
+
)
|
|
456
513
|
|
|
457
514
|
if json_output:
|
|
458
515
|
typer.echo(
|
|
@@ -473,7 +530,7 @@ def verify_status(
|
|
|
473
530
|
typer.echo(payload.message)
|
|
474
531
|
if payload.summary:
|
|
475
532
|
typer.echo(f"\nSummary:\n{payload.summary}")
|
|
476
|
-
if
|
|
533
|
+
if screenshots_saved:
|
|
477
534
|
typer.echo(
|
|
478
535
|
f"\nScreenshots ({screenshot_count}) saved to "
|
|
479
536
|
f".assertion/screenshots/{session_id}/"
|
|
@@ -62,11 +62,22 @@ class ErrorResponse(BaseModel):
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class MetadataPayload(BaseModel):
|
|
65
|
+
"""Local on-disk schema for `.assertion/metadata.json`.
|
|
66
|
+
|
|
67
|
+
`base_sha` is the diff base (the commit `asrt init` recorded; the commit
|
|
68
|
+
the backend checks out before applying the diff). In the bundle sent to
|
|
69
|
+
the backend it is remapped to `head_sha` for wire compatibility — from
|
|
70
|
+
the backend's perspective, after `git checkout <sha>`, that SHA IS its
|
|
71
|
+
HEAD, so the name reads correctly on the consumer side. Locally we keep
|
|
72
|
+
`base_sha` because it matches what the CLI stores (`.assertion/base_sha`)
|
|
73
|
+
and what the value represents from the CLI's perspective.
|
|
74
|
+
"""
|
|
75
|
+
|
|
65
76
|
model_config = ConfigDict(extra="allow")
|
|
66
77
|
|
|
67
78
|
session_id: str
|
|
68
79
|
stack_id: str | None = None
|
|
69
|
-
|
|
80
|
+
base_sha: str | None = None
|
|
70
81
|
head_branch: str | None = None
|
|
71
82
|
|
|
72
83
|
|
|
@@ -6,8 +6,6 @@ from api import AssertionClient
|
|
|
6
6
|
from git import (
|
|
7
7
|
exit_with_error,
|
|
8
8
|
find_git_root,
|
|
9
|
-
get_diff_base_sha,
|
|
10
|
-
get_origin_github_repo,
|
|
11
9
|
)
|
|
12
10
|
from models import MetadataPayload, SessionContext, render_stack_list
|
|
13
11
|
|
|
@@ -15,13 +13,35 @@ ASSERTION_DIR_NAME = ".assertion"
|
|
|
15
13
|
PROMPTS_FILE_NAME = "prompts"
|
|
16
14
|
CHECKPOINT_FILE_NAME = "checkpoint"
|
|
17
15
|
METADATA_FILE_NAME = "metadata.json"
|
|
16
|
+
BASE_SHA_FILE_NAME = "base_sha"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_init_base_sha(assertion_dir: Path) -> str:
|
|
20
|
+
"""Return the diff base SHA recorded by `asrt init`.
|
|
21
|
+
|
|
22
|
+
Exits with a clear error if init hasn't been run or the file is empty.
|
|
23
|
+
The base SHA is the commit the backend `git checkout`s against before
|
|
24
|
+
applying the diff, so every checkpoint/verify must have it.
|
|
25
|
+
"""
|
|
26
|
+
path = assertion_dir / BASE_SHA_FILE_NAME
|
|
27
|
+
if not path.exists():
|
|
28
|
+
exit_with_error(
|
|
29
|
+
"No diff base recorded for this repo. Run `asrt init` first — "
|
|
30
|
+
"it captures the HEAD SHA that checkpoint/verify diff against."
|
|
31
|
+
)
|
|
32
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
33
|
+
if not content:
|
|
34
|
+
exit_with_error(
|
|
35
|
+
f"{path.name} is empty. Re-run `asrt init` to record the diff base."
|
|
36
|
+
)
|
|
37
|
+
return content
|
|
18
38
|
|
|
19
39
|
|
|
20
40
|
def _require_repo_ready(start_path: Path) -> Path:
|
|
21
41
|
repo_root = find_git_root(start_path)
|
|
22
|
-
# Fail fast
|
|
23
|
-
#
|
|
24
|
-
|
|
42
|
+
# Fail fast if `asrt init` hasn't run — checkpoint/verify need the base
|
|
43
|
+
# SHA it records before doing any work or making any network calls.
|
|
44
|
+
read_init_base_sha(repo_root / ASSERTION_DIR_NAME)
|
|
25
45
|
return repo_root
|
|
26
46
|
|
|
27
47
|
|
|
@@ -101,50 +121,6 @@ def _stacks_hint() -> str:
|
|
|
101
121
|
)
|
|
102
122
|
|
|
103
123
|
|
|
104
|
-
def resolve_stack_id_for_repo(repo_root: Path) -> str:
|
|
105
|
-
"""Pick the stack whose `repo` field matches the current repo's origin.
|
|
106
|
-
|
|
107
|
-
Exits with a clear error if zero or more than one stack matches. The
|
|
108
|
-
enforcement is here (in the CLI) so the coding agent does not have to
|
|
109
|
-
follow a compliance rule — the rule is mechanical.
|
|
110
|
-
"""
|
|
111
|
-
owner_name = get_origin_github_repo(repo_root)
|
|
112
|
-
if owner_name is None:
|
|
113
|
-
exit_with_error(
|
|
114
|
-
"Could not determine this repo's GitHub `owner/name` from the `origin` "
|
|
115
|
-
"remote. Pass `--stack <id>` explicitly to override."
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
client = AssertionClient()
|
|
119
|
-
stacks = client.stacks()
|
|
120
|
-
|
|
121
|
-
if not stacks:
|
|
122
|
-
exit_with_error(
|
|
123
|
-
"No verification stacks exist in this workspace yet. Open the "
|
|
124
|
-
"Assertion web app, create a stack, and attach this repo "
|
|
125
|
-
f"(`{owner_name}`) to it. Then re-run the command."
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
matches = [s for s in stacks if s.repo.strip() == owner_name]
|
|
129
|
-
|
|
130
|
-
if len(matches) == 1:
|
|
131
|
-
return matches[0].id
|
|
132
|
-
if len(matches) == 0:
|
|
133
|
-
bound_repos = sorted({s.repo.strip() for s in stacks})
|
|
134
|
-
exit_with_error(
|
|
135
|
-
f"No verification stack is attached to this repo (`{owner_name}`)."
|
|
136
|
-
f" Other stacks in this workspace are attached to: {', '.join(bound_repos)}."
|
|
137
|
-
" Open the Assertion web app, choose a stack,"
|
|
138
|
-
" and attach this repo to it. Then re-run the command."
|
|
139
|
-
)
|
|
140
|
-
# More than one match — let the agent / user pick explicitly.
|
|
141
|
-
names = ", ".join(f"{s.name} ({s.id})" for s in matches)
|
|
142
|
-
exit_with_error(
|
|
143
|
-
f"Multiple stacks are attached to `{owner_name}`: {names}. "
|
|
144
|
-
"Pass `--stack <id>` to disambiguate."
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
|
|
148
124
|
def start_new_session(
|
|
149
125
|
*,
|
|
150
126
|
start_path: Path,
|
|
@@ -209,14 +185,20 @@ def append_checkpoint_entry(checkpoint_path: Path, message: str) -> None:
|
|
|
209
185
|
f.write(f"{normalized}\n\n")
|
|
210
186
|
|
|
211
187
|
|
|
212
|
-
def
|
|
188
|
+
def update_metadata_anchor(
|
|
213
189
|
metadata_path: Path,
|
|
214
190
|
metadata: MetadataPayload,
|
|
215
|
-
|
|
191
|
+
base_sha: str,
|
|
216
192
|
head_branch: str | None = None,
|
|
217
193
|
) -> MetadataPayload:
|
|
194
|
+
"""Refresh the per-session git anchor: diff base + branch name.
|
|
195
|
+
|
|
196
|
+
`base_sha` is the diff base (recorded by `asrt init`, same value on every
|
|
197
|
+
call within a session). `head_branch` is the current local branch — may
|
|
198
|
+
legitimately change within a session if the user renames or switches.
|
|
199
|
+
"""
|
|
218
200
|
updated = metadata.model_copy(
|
|
219
|
-
update={"
|
|
201
|
+
update={"base_sha": base_sha, "head_branch": head_branch}
|
|
220
202
|
)
|
|
221
203
|
metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
222
204
|
return updated
|
|
@@ -8,7 +8,7 @@ This repo uses the Assertion CLI (`asrt`) to track user intent and verify your w
|
|
|
8
8
|
Both files are byte-identical. Read whichever your agent loads (or either one directly). In brief, follow these three rules for every conversation:
|
|
9
9
|
|
|
10
10
|
1. **Every user message:** call `asrt prompt "<exact text of the message>"` before doing any other work for that turn. This is the verifier's source of truth for what the user asked for and how their direction evolves. Apply to every user turn, not just the first.
|
|
11
|
-
2. **Checkpoint at trajectory-feedback moments**, not on every small change.
|
|
11
|
+
2. **Checkpoint at trajectory-feedback moments**, not on every small change. On the first checkpoint of a session, run `asrt stacks`, pick the stack that fits this repo and the work (use the stack's `repo`, `name`, `description`, plus folder/branch/README signals), then run `asrt checkpoint --stack <id> "<summary>"`. Subsequent checkpoints in the same session don't need `--stack` — `asrt checkpoint "<summary>"` auto-continues. A whole feature build is a handful of checkpoints, not one per file.
|
|
12
12
|
3. **Verify at completion** with `asrt verify`. If feedback requires changes, iterate and re-verify — do **not** drop back into the checkpoint loop. Once verify is clean, open a PR with `gh pr create` and include the session URL (from `asrt get-link`) in the body.
|
|
13
13
|
|
|
14
14
|
Do not open a PR without a clean `asrt verify`.
|