assertion-cli 0.5.14__tar.gz → 0.5.16__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.5.14 → assertion_cli-0.5.16}/PKG-INFO +2 -2
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/README.md +1 -1
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/PKG-INFO +2 -2
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/SOURCES.txt +3 -1
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/git.py +54 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/main.py +127 -27
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/pyproject.toml +1 -1
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/session.py +35 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/AGENTS.md +2 -2
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/SKILL.md +25 -9
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_install.py +9 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_session_start.py +21 -0
- assertion_cli-0.5.16/tests/test_verify.py +332 -0
- assertion_cli-0.5.16/tests/test_verify_integration.py +232 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/api.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/bundle.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/credentials.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/link.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/models.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/setup.cfg +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/__init__.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_api.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_auth.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_credentials.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_git.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_link.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_main.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_session.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.16
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -42,12 +42,12 @@ After installation:
|
|
|
42
42
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
43
43
|
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
44
44
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
45
|
-
asrt new # reset local run state for a fresh session
|
|
46
45
|
asrt stacks
|
|
47
46
|
asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
|
|
48
47
|
asrt checkpoint --continue "Implemented Y"
|
|
49
48
|
asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
|
|
50
49
|
asrt verify # submit final verification (non-blocking)
|
|
50
|
+
asrt verify --force # submit even when review diff is unchanged
|
|
51
51
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -29,12 +29,12 @@ After installation:
|
|
|
29
29
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
30
30
|
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
31
31
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
32
|
-
asrt new # reset local run state for a fresh session
|
|
33
32
|
asrt stacks
|
|
34
33
|
asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
|
|
35
34
|
asrt checkpoint --continue "Implemented Y"
|
|
36
35
|
asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
|
|
37
36
|
asrt verify # submit final verification (non-blocking)
|
|
37
|
+
asrt verify --force # submit even when review diff is unchanged
|
|
38
38
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.16
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -42,12 +42,12 @@ After installation:
|
|
|
42
42
|
asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
|
|
43
43
|
asrt login # authenticate this repo (generate a token at Settings → Authentication)
|
|
44
44
|
asrt session start # per work session: record the diff base (current HEAD)
|
|
45
|
-
asrt new # reset local run state for a fresh session
|
|
46
45
|
asrt stacks
|
|
47
46
|
asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
|
|
48
47
|
asrt checkpoint --continue "Implemented Y"
|
|
49
48
|
asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
|
|
50
49
|
asrt verify # submit final verification (non-blocking)
|
|
50
|
+
asrt verify --force # submit even when review diff is unchanged
|
|
51
51
|
asrt verify-status # one-shot poll; loop with your own sleep until terminal
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -180,6 +180,60 @@ def get_upstream_ref(repo_root: Path) -> str | None:
|
|
|
180
180
|
return None
|
|
181
181
|
|
|
182
182
|
|
|
183
|
+
def get_closest_merged_remote_ref(repo_root: Path) -> str | None:
|
|
184
|
+
"""Return the newest remote-tracking ref that is an ancestor of ``HEAD``.
|
|
185
|
+
|
|
186
|
+
Used when ``get_upstream_ref`` falls through to a raw merge-base SHA so
|
|
187
|
+
session-start output can still name the branch the user branched from.
|
|
188
|
+
"""
|
|
189
|
+
try:
|
|
190
|
+
merged = run_git_command(
|
|
191
|
+
repo_root,
|
|
192
|
+
[
|
|
193
|
+
"branch",
|
|
194
|
+
"-r",
|
|
195
|
+
"--merged",
|
|
196
|
+
"HEAD",
|
|
197
|
+
"--format=%(refname:short)",
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
except RuntimeError:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
ancestors = [r for r in merged.splitlines() if r]
|
|
204
|
+
if not ancestors:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
newest_ref: str | None = None
|
|
208
|
+
newest_ts = -1
|
|
209
|
+
for ref in ancestors:
|
|
210
|
+
try:
|
|
211
|
+
ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", ref]))
|
|
212
|
+
except (RuntimeError, ValueError):
|
|
213
|
+
continue
|
|
214
|
+
if ts > newest_ts:
|
|
215
|
+
newest_ts = ts
|
|
216
|
+
newest_ref = ref
|
|
217
|
+
|
|
218
|
+
return newest_ref
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def format_verify_scope_label(repo_root: Path, upstream: str) -> str:
|
|
222
|
+
"""Human-readable label for the upstream used to compute the verify base."""
|
|
223
|
+
if upstream.startswith("origin/") or upstream == "origin/HEAD":
|
|
224
|
+
return f"merge-base with {upstream}"
|
|
225
|
+
|
|
226
|
+
# Step 3 of ``get_upstream_ref`` returns a raw SHA; name the parent branch.
|
|
227
|
+
closest = get_closest_merged_remote_ref(repo_root)
|
|
228
|
+
if closest is not None:
|
|
229
|
+
return f"merge-base with {closest}"
|
|
230
|
+
|
|
231
|
+
if len(upstream) >= 12 and all(c in "0123456789abcdef" for c in upstream.lower()):
|
|
232
|
+
return f"merge-base at {upstream[:12]}"
|
|
233
|
+
|
|
234
|
+
return f"merge-base with {upstream}"
|
|
235
|
+
|
|
236
|
+
|
|
183
237
|
def get_merge_base(repo_root: Path, upstream: str) -> str:
|
|
184
238
|
"""Return the merge-base of ``HEAD`` and *upstream*."""
|
|
185
239
|
try:
|
|
@@ -17,6 +17,7 @@ from git import (
|
|
|
17
17
|
exit_with_error,
|
|
18
18
|
find_git_root,
|
|
19
19
|
find_git_root_or_none,
|
|
20
|
+
format_verify_scope_label,
|
|
20
21
|
get_full_diff,
|
|
21
22
|
get_head_branch,
|
|
22
23
|
get_head_sha,
|
|
@@ -25,17 +26,23 @@ from git import (
|
|
|
25
26
|
is_ancestor,
|
|
26
27
|
run_git_command,
|
|
27
28
|
)
|
|
28
|
-
from link import load_link, save_link
|
|
29
|
+
from link import LINK_FILE_NAME, load_link, save_link
|
|
29
30
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
30
31
|
from session import (
|
|
31
32
|
ASSERTION_DIR_NAME,
|
|
32
33
|
METADATA_FILE_NAME,
|
|
33
34
|
PROMPTS_FILE_NAME,
|
|
34
35
|
VERIFY_BASE_SHA_FILE_NAME,
|
|
36
|
+
_read_optional_text_file,
|
|
35
37
|
append_checkpoint_entry,
|
|
38
|
+
compute_review_diff_hash,
|
|
36
39
|
continue_session,
|
|
37
40
|
load_existing_session,
|
|
38
41
|
read_init_verify_base_sha,
|
|
42
|
+
read_review_diff_hash,
|
|
43
|
+
read_verify_last_status,
|
|
44
|
+
save_review_diff_hash,
|
|
45
|
+
save_verify_last_status,
|
|
39
46
|
start_new_session,
|
|
40
47
|
update_metadata_anchor,
|
|
41
48
|
)
|
|
@@ -360,8 +367,10 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
|
|
|
360
367
|
app.add_typer(session_app, name="session")
|
|
361
368
|
|
|
362
369
|
|
|
363
|
-
def
|
|
364
|
-
|
|
370
|
+
def _resolve_verify_scope(
|
|
371
|
+
repo_root: Path, from_ref: str | None
|
|
372
|
+
) -> tuple[str, str]:
|
|
373
|
+
"""Resolve the verification base SHA and a human-readable scope label.
|
|
365
374
|
|
|
366
375
|
If *from_ref* is provided, resolve it and validate that it is an ancestor
|
|
367
376
|
of HEAD. Otherwise compute the merge-base with the upstream tracking branch,
|
|
@@ -374,7 +383,7 @@ def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
|
|
|
374
383
|
exit_with_error(f"Invalid --from ref '{from_ref}': {exc}")
|
|
375
384
|
if not is_ancestor(repo_root, sha, "HEAD"):
|
|
376
385
|
exit_with_error(f"--from ref {sha[:12]} is not an ancestor of HEAD.")
|
|
377
|
-
return sha
|
|
386
|
+
return sha, f"explicit ref {from_ref}"
|
|
378
387
|
|
|
379
388
|
try:
|
|
380
389
|
run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
|
|
@@ -387,13 +396,14 @@ def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
|
|
|
387
396
|
"Warning: no upstream tracking branch found, falling back to HEAD.",
|
|
388
397
|
err=True,
|
|
389
398
|
)
|
|
390
|
-
return get_head_sha(repo_root)
|
|
399
|
+
return get_head_sha(repo_root), "HEAD (no upstream found)"
|
|
391
400
|
try:
|
|
392
|
-
|
|
401
|
+
sha = run_git_command(repo_root, ["merge-base", "HEAD", upstream])
|
|
393
402
|
except RuntimeError as exc:
|
|
394
403
|
exit_with_error(
|
|
395
404
|
f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
|
|
396
405
|
)
|
|
406
|
+
return sha, format_verify_scope_label(repo_root, upstream)
|
|
397
407
|
|
|
398
408
|
|
|
399
409
|
def _resolve_clone_anchor(repo_root: Path, verify_base: str) -> str:
|
|
@@ -452,7 +462,7 @@ def session_start(
|
|
|
452
462
|
repo_root = find_git_root(Path.cwd())
|
|
453
463
|
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
454
464
|
|
|
455
|
-
verify_base =
|
|
465
|
+
verify_base, scope_label = _resolve_verify_scope(repo_root, from_ref)
|
|
456
466
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
457
467
|
assertion_dir.mkdir(exist_ok=True)
|
|
458
468
|
removed = _reset_assertion_dir(assertion_dir)
|
|
@@ -460,7 +470,8 @@ def session_start(
|
|
|
460
470
|
verify_base_path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
|
|
461
471
|
verify_base_path.write_text(verify_base + "\n", encoding="utf-8")
|
|
462
472
|
typer.echo(
|
|
463
|
-
f"Recorded verification base {verify_base[:12]}
|
|
473
|
+
f"Recorded verification base {verify_base[:12]} ({scope_label}) "
|
|
474
|
+
f"→ {verify_base_path.relative_to(repo_root)}"
|
|
464
475
|
)
|
|
465
476
|
if removed:
|
|
466
477
|
typer.echo(
|
|
@@ -469,7 +480,8 @@ def session_start(
|
|
|
469
480
|
|
|
470
481
|
typer.echo("")
|
|
471
482
|
typer.echo("The coding agent will now follow the Assertion workflow:")
|
|
472
|
-
typer.echo(' - asrt prompt "<msg>"
|
|
483
|
+
typer.echo(' - in non-Plan mode, asrt prompt "<msg>" for each user turn')
|
|
484
|
+
typer.echo(' - when starting from a finalized plan, log it with asrt prompt')
|
|
473
485
|
typer.echo(" - asrt checkpoint at trajectory-feedback moments")
|
|
474
486
|
typer.echo(" - asrt verify at completion, then PR")
|
|
475
487
|
|
|
@@ -602,6 +614,14 @@ def _save_verify_session_id(assertion_dir: Path, session_id: str) -> None:
|
|
|
602
614
|
)
|
|
603
615
|
|
|
604
616
|
|
|
617
|
+
def _read_optional_verify_session_id(assertion_dir: Path) -> str | None:
|
|
618
|
+
path = assertion_dir / VERIFY_SESSION_FILE_NAME
|
|
619
|
+
if not path.exists():
|
|
620
|
+
return None
|
|
621
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
622
|
+
return content or None
|
|
623
|
+
|
|
624
|
+
|
|
605
625
|
def _load_verify_session_id(assertion_dir: Path) -> str:
|
|
606
626
|
path = assertion_dir / VERIFY_SESSION_FILE_NAME
|
|
607
627
|
if not path.exists():
|
|
@@ -647,6 +667,82 @@ def _write_screenshots(
|
|
|
647
667
|
return screenshot_dir
|
|
648
668
|
|
|
649
669
|
|
|
670
|
+
def _build_verify_bundle(
|
|
671
|
+
ctx,
|
|
672
|
+
metadata,
|
|
673
|
+
) -> tuple[bytes, str, str]:
|
|
674
|
+
"""Return (bundle_bytes, review_diff_text, review_diff_hash)."""
|
|
675
|
+
verify_base = read_init_verify_base_sha(ctx.assertion_dir)
|
|
676
|
+
clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
|
|
677
|
+
head_branch = get_head_branch(ctx.repo_root)
|
|
678
|
+
diff_text = get_full_diff(ctx.repo_root, clone_anchor)
|
|
679
|
+
review_diff_text = get_full_diff(ctx.repo_root, verify_base)
|
|
680
|
+
metadata = update_metadata_anchor(
|
|
681
|
+
ctx.metadata_path,
|
|
682
|
+
metadata,
|
|
683
|
+
verify_base,
|
|
684
|
+
clone_sha=clone_anchor,
|
|
685
|
+
head_branch=head_branch,
|
|
686
|
+
)
|
|
687
|
+
bundle_bytes = build_bundle(
|
|
688
|
+
metadata=metadata,
|
|
689
|
+
diff_text=diff_text,
|
|
690
|
+
review_diff_text=review_diff_text,
|
|
691
|
+
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
692
|
+
checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
|
|
693
|
+
)
|
|
694
|
+
return bundle_bytes, review_diff_text, compute_review_diff_hash(review_diff_text)
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _try_reuse_successful_verify(
|
|
698
|
+
assertion_dir: Path,
|
|
699
|
+
*,
|
|
700
|
+
review_diff_hash: str,
|
|
701
|
+
json_output: bool,
|
|
702
|
+
) -> bool:
|
|
703
|
+
"""Reuse the prior verification when the review diff is unchanged.
|
|
704
|
+
|
|
705
|
+
Returns True when a prior succeeded verification is reused (no new submission).
|
|
706
|
+
"""
|
|
707
|
+
stored_hash = read_review_diff_hash(assertion_dir)
|
|
708
|
+
if stored_hash != review_diff_hash:
|
|
709
|
+
return False
|
|
710
|
+
|
|
711
|
+
last_status = read_verify_last_status(assertion_dir)
|
|
712
|
+
if last_status != SessionStatus.SUCCEEDED.value:
|
|
713
|
+
return False
|
|
714
|
+
|
|
715
|
+
session_id = _read_optional_verify_session_id(assertion_dir)
|
|
716
|
+
if session_id is None:
|
|
717
|
+
return False
|
|
718
|
+
|
|
719
|
+
session_url = _read_optional_text_file(assertion_dir / LINK_FILE_NAME)
|
|
720
|
+
if session_url is None:
|
|
721
|
+
return False
|
|
722
|
+
|
|
723
|
+
if json_output:
|
|
724
|
+
typer.echo(
|
|
725
|
+
json.dumps(
|
|
726
|
+
{
|
|
727
|
+
"session_id": session_id,
|
|
728
|
+
"url": session_url,
|
|
729
|
+
"status": SessionStatus.SUCCEEDED.value,
|
|
730
|
+
"reused": True,
|
|
731
|
+
}
|
|
732
|
+
)
|
|
733
|
+
)
|
|
734
|
+
else:
|
|
735
|
+
typer.echo(
|
|
736
|
+
"Review diff unchanged since the last successful verification — "
|
|
737
|
+
f"reusing session {session_id}."
|
|
738
|
+
)
|
|
739
|
+
typer.echo(f"URL: {session_url}")
|
|
740
|
+
typer.echo(
|
|
741
|
+
"Embed `asrt get-link` in the PR body if the branch name may differ."
|
|
742
|
+
)
|
|
743
|
+
return True
|
|
744
|
+
|
|
745
|
+
|
|
650
746
|
@app.command("verify")
|
|
651
747
|
def verify(
|
|
652
748
|
json_output: bool = typer.Option(
|
|
@@ -654,6 +750,11 @@ def verify(
|
|
|
654
750
|
"--json",
|
|
655
751
|
help="Print the verification submission as a single JSON line.",
|
|
656
752
|
),
|
|
753
|
+
force: bool = typer.Option(
|
|
754
|
+
False,
|
|
755
|
+
"--force",
|
|
756
|
+
help="Submit a new verification even when the review diff is unchanged.",
|
|
757
|
+
),
|
|
657
758
|
) -> None:
|
|
658
759
|
"""Submit final verification. Returns immediately without polling.
|
|
659
760
|
|
|
@@ -661,30 +762,24 @@ def verify(
|
|
|
661
762
|
shell long enough to trip a Bash-tool timeout was a real failure mode. So
|
|
662
763
|
this command only submits — poll `asrt verify-status` (one-shot) on whatever
|
|
663
764
|
interval your harness prefers to learn the outcome.
|
|
765
|
+
|
|
766
|
+
When the review-scope diff is unchanged since the last *succeeded*
|
|
767
|
+
verification, the CLI reuses that result instead of submitting again.
|
|
768
|
+
Pass ``--force`` to always submit.
|
|
664
769
|
"""
|
|
665
770
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
666
771
|
stack_id = metadata.stack_id
|
|
667
772
|
assert stack_id is not None
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
head_branch = get_head_branch(ctx.repo_root)
|
|
671
|
-
diff_text = get_full_diff(ctx.repo_root, clone_anchor)
|
|
672
|
-
review_diff_text = get_full_diff(ctx.repo_root, verify_base)
|
|
673
|
-
metadata = update_metadata_anchor(
|
|
674
|
-
ctx.metadata_path,
|
|
675
|
-
metadata,
|
|
676
|
-
verify_base,
|
|
677
|
-
clone_sha=clone_anchor,
|
|
678
|
-
head_branch=head_branch,
|
|
773
|
+
bundle_bytes, review_diff_text, review_diff_hash = _build_verify_bundle(
|
|
774
|
+
ctx, metadata
|
|
679
775
|
)
|
|
680
776
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
)
|
|
777
|
+
if not force and _try_reuse_successful_verify(
|
|
778
|
+
ctx.assertion_dir,
|
|
779
|
+
review_diff_hash=review_diff_hash,
|
|
780
|
+
json_output=json_output,
|
|
781
|
+
):
|
|
782
|
+
return
|
|
688
783
|
|
|
689
784
|
client = AssertionClient()
|
|
690
785
|
resp = client.verify(
|
|
@@ -697,6 +792,8 @@ def verify(
|
|
|
697
792
|
session_url = resp.url
|
|
698
793
|
save_link(ctx.assertion_dir, session_url)
|
|
699
794
|
_save_verify_session_id(ctx.assertion_dir, session_id)
|
|
795
|
+
save_review_diff_hash(ctx.assertion_dir, review_diff_hash)
|
|
796
|
+
save_verify_last_status(ctx.assertion_dir, SessionStatus.RUNNING.value)
|
|
700
797
|
|
|
701
798
|
if json_output:
|
|
702
799
|
typer.echo(
|
|
@@ -705,6 +802,7 @@ def verify(
|
|
|
705
802
|
"session_id": session_id,
|
|
706
803
|
"url": session_url,
|
|
707
804
|
"status": "submitted",
|
|
805
|
+
"reused": False,
|
|
708
806
|
}
|
|
709
807
|
)
|
|
710
808
|
)
|
|
@@ -747,6 +845,8 @@ def verify_status(
|
|
|
747
845
|
session_url = None
|
|
748
846
|
|
|
749
847
|
terminal = payload.status in {SessionStatus.SUCCEEDED, SessionStatus.FAILED}
|
|
848
|
+
if terminal:
|
|
849
|
+
save_verify_last_status(ctx.assertion_dir, str(payload.status))
|
|
750
850
|
screenshot_count = len(payload.screenshots)
|
|
751
851
|
screenshots_saved = False
|
|
752
852
|
if terminal and payload.screenshots:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
from pydantic import ValidationError
|
|
@@ -14,6 +15,40 @@ PROMPTS_FILE_NAME = "prompts"
|
|
|
14
15
|
CHECKPOINT_FILE_NAME = "checkpoint"
|
|
15
16
|
METADATA_FILE_NAME = "metadata.json"
|
|
16
17
|
VERIFY_BASE_SHA_FILE_NAME = "verify_base_sha"
|
|
18
|
+
REVIEW_DIFF_HASH_FILE_NAME = "review_diff_hash"
|
|
19
|
+
VERIFY_LAST_STATUS_FILE_NAME = "verify_last_status"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def compute_review_diff_hash(review_diff_text: str) -> str:
|
|
23
|
+
"""Stable hash of the review-scope diff (verify base → working tree)."""
|
|
24
|
+
return hashlib.sha256(review_diff_text.encode("utf-8")).hexdigest()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_optional_text_file(path: Path) -> str | None:
|
|
28
|
+
if not path.exists():
|
|
29
|
+
return None
|
|
30
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
31
|
+
return content or None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_review_diff_hash(assertion_dir: Path) -> str | None:
|
|
35
|
+
return _read_optional_text_file(assertion_dir / REVIEW_DIFF_HASH_FILE_NAME)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_review_diff_hash(assertion_dir: Path, digest: str) -> None:
|
|
39
|
+
(assertion_dir / REVIEW_DIFF_HASH_FILE_NAME).write_text(
|
|
40
|
+
digest + "\n", encoding="utf-8"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def read_verify_last_status(assertion_dir: Path) -> str | None:
|
|
45
|
+
return _read_optional_text_file(assertion_dir / VERIFY_LAST_STATUS_FILE_NAME)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_verify_last_status(assertion_dir: Path, status: str) -> None:
|
|
49
|
+
(assertion_dir / VERIFY_LAST_STATUS_FILE_NAME).write_text(
|
|
50
|
+
status + "\n", encoding="utf-8"
|
|
51
|
+
)
|
|
17
52
|
|
|
18
53
|
|
|
19
54
|
def read_init_verify_base_sha(assertion_dir: Path) -> str:
|
|
@@ -5,9 +5,9 @@ This repo uses the Assertion CLI (`asrt`) to track user intent and verify your w
|
|
|
5
5
|
- **Claude Code** — [`.claude/skills/assertion-cli/SKILL.md`](.claude/skills/assertion-cli/SKILL.md)
|
|
6
6
|
- **Codex / Cursor** — [`.agents/skills/assertion-cli/SKILL.md`](.agents/skills/assertion-cli/SKILL.md)
|
|
7
7
|
|
|
8
|
-
Both files are byte-identical — read whichever your agent loads (or either one directly). In brief, follow these three rules
|
|
8
|
+
Both files are byte-identical — read whichever your agent loads (or either one directly). In brief, follow these three rules in non-Plan mode:
|
|
9
9
|
|
|
10
|
-
1. **
|
|
10
|
+
1. **Prompts:** do not call `asrt prompt` during Plan Mode. When implementation begins from a finalized plan, record that plan with `asrt prompt "<finalized plan text>"`; after that, call `asrt prompt "<exact text of the message>"` for each non-Plan user turn before doing other work.
|
|
11
11
|
2. **Checkpoint at trajectory-feedback moments** with `asrt checkpoint` — a handful across a feature build, not one per file.
|
|
12
12
|
3. **Verify, then PR with the session marker.** At completion, run `asrt verify` and poll `asrt verify-status` until clean. Then open the PR and embed the hidden session marker — `$(asrt get-link)`, an HTML comment — in its body.
|
|
13
13
|
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assertion-cli
|
|
3
|
-
description: Use this skill in non-Plan mode whenever working in a repo that uses the Assertion CLI (`asrt`). It enforces the three-rule workflow — log
|
|
3
|
+
description: Use this skill in non-Plan mode whenever working in a repo that uses the Assertion CLI (`asrt`). It enforces the three-rule workflow — log non-Plan user messages and finalized implementation plans with `asrt prompt`, checkpoint at trajectory-feedback moments (not on every change), and verify only at completion before opening a PR whose body embeds the hidden Assertion session marker. Applies to any coding agent reading this skill.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Assertion CLI
|
|
7
7
|
|
|
8
|
-
You are operating in a repo that uses the Assertion CLI (`asrt`) to track user intent and verify your work. Follow the three rules below without exception. They apply regardless of which coding agent harness you are running under (Claude Code, Codex, Cursor, etc.).
|
|
8
|
+
You are operating in a repo that uses the Assertion CLI (`asrt`) to track user intent and verify your work. Follow the three rules below without exception in non-Plan mode. They apply regardless of which coding agent harness you are running under (Claude Code, Codex, Cursor, etc.).
|
|
9
9
|
|
|
10
10
|
This skill is operational, not advisory. Treat each rule as a hard requirement unless the underlying service is unavailable.
|
|
11
11
|
|
|
12
|
+
## Plan Mode boundary
|
|
13
|
+
|
|
14
|
+
When the harness is in Plan Mode, do not run `asrt prompt`, `asrt checkpoint`, `asrt verify`, or PR commands. Plan Mode is for producing a plan, not for mutating Assertion session state.
|
|
15
|
+
|
|
16
|
+
When the harness leaves Plan Mode and begins implementation from a finalized plan, first record that plan with `asrt prompt "<finalized plan text>"`. Then proceed with the non-Plan workflow below, including logging any later user messages as they arrive.
|
|
17
|
+
|
|
12
18
|
## Preflight (one-time per session)
|
|
13
19
|
|
|
14
20
|
Before any `asrt` call:
|
|
@@ -21,7 +27,8 @@ Before any `asrt` call:
|
|
|
21
27
|
**First command sequence for a new work session:**
|
|
22
28
|
|
|
23
29
|
1. Run `asrt session start` to start fresh. This clears previous in-flight Assertion state (metadata, prompts log, link, verification status, screenshots) and records the current verification base.
|
|
24
|
-
2.
|
|
30
|
+
2. If starting from a finalized Plan Mode plan, immediately log that plan with `asrt prompt`.
|
|
31
|
+
3. Otherwise, immediately log the active non-Plan user prompt with `asrt prompt`.
|
|
25
32
|
|
|
26
33
|
Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/verify_base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually — use `asrt prompt` to append to the prompts log and `asrt session start` to reset for a fresh session.
|
|
27
34
|
|
|
@@ -29,17 +36,23 @@ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/verify_b
|
|
|
29
36
|
|
|
30
37
|
---
|
|
31
38
|
|
|
32
|
-
## Rule 1 — Log
|
|
39
|
+
## Rule 1 — Log non-Plan user messages and finalized plans, immediately
|
|
33
40
|
|
|
34
|
-
**
|
|
41
|
+
**In non-Plan mode, the first thing you do when a new user message arrives — before reading any file, planning, or editing anything — is run:**
|
|
35
42
|
|
|
36
43
|
```
|
|
37
44
|
asrt prompt "<exact text of the user's message>"
|
|
38
45
|
```
|
|
39
46
|
|
|
40
|
-
|
|
47
|
+
Plan Mode exception: if the harness is currently in Plan Mode, do not call `asrt prompt` for each planning turn. When implementation begins from the finalized plan, record the plan itself as the first prompt:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
asrt prompt "<finalized plan text>"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Fresh-session exception: on the first non-Plan user turn of a new work session, perform the Preflight fresh-session check first by running `asrt session start`, then immediately run `asrt prompt` for the active user message or finalized plan so the new/current session records the user's intent.
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
Outside Plan Mode, this applies to **every user turn**, not just the first. It applies even when the message is a one-word direction change ("nope"), a clarification ("use Postgres, not SQLite"), or a follow-up after verify ("also add dark mode").
|
|
43
56
|
|
|
44
57
|
**Why:** The prompts log is the verifier's source of truth for what the user asked for and how their direction evolved. It is shipped with every checkpoint and the final verify, and persisted in the verification database. A missed turn causes the verifier to flag legitimate work as off-spec because it cannot see the intent change that authorized the work.
|
|
45
58
|
|
|
@@ -131,6 +144,8 @@ Do not call `verify` mid-task. It is not a substitute for checkpoints; it is the
|
|
|
131
144
|
|
|
132
145
|
`verify` is **non-blocking**: it submits the verification and returns immediately with the session id and URL. The verification itself can take several minutes — you poll `asrt verify-status` on your own cadence to learn the outcome. This avoids tripping any shell-timeout in the harness.
|
|
133
146
|
|
|
147
|
+
When the review diff is unchanged since the last **succeeded** verification, `asrt verify` reuses that session instead of submitting again. Use `asrt verify --force` to submit anyway. After a new git branch, run `asrt session start` only when the verification base should change — check the scope line it prints, or pass `--from <ref>` if the default is wrong.
|
|
148
|
+
|
|
134
149
|
```
|
|
135
150
|
asrt verify # submit; prints session id + URL, exits 0
|
|
136
151
|
# … then poll until terminal:
|
|
@@ -207,13 +222,14 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
|
|
|
207
222
|
## Quick reference
|
|
208
223
|
|
|
209
224
|
```
|
|
210
|
-
asrt prompt "<message>" #
|
|
225
|
+
asrt prompt "<message>" # non-Plan user turn or finalized plan, before work
|
|
211
226
|
asrt session start [--from <ref>] # start fresh; optional explicit verification base
|
|
212
227
|
asrt stacks # list every stack — read once before first checkpoint
|
|
213
228
|
asrt checkpoint --stack <id> "..." # first checkpoint of a session
|
|
214
229
|
asrt checkpoint "..." # subsequent checkpoints — auto-continues
|
|
215
230
|
asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
|
|
216
231
|
asrt verify # submit verification (non-blocking)
|
|
232
|
+
asrt verify --force # submit even when review diff unchanged
|
|
217
233
|
asrt verify-status # one-shot poll; loop with your own sleep
|
|
218
234
|
asrt get-link # hidden PR marker — embed in the PR body (required)
|
|
219
235
|
```
|
|
@@ -231,7 +247,7 @@ When running under a non-interactive harness (CI, benchmark runner, scripts):
|
|
|
231
247
|
- Apply changes targeted at the cited failure.
|
|
232
248
|
- Re-run `asrt checkpoint "<what you changed>" --json` (auto-continues the existing session).
|
|
233
249
|
- Bound retries: stop after 3 attempts on the same failure mode, or when the same `reasoning` is returned twice in a row.
|
|
234
|
-
4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"}`. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
|
|
250
|
+
4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"|"succeeded", "reused": bool}`. When `"reused": true`, the review diff matched the last succeeded verification and no new backend run was started. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
|
|
235
251
|
5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch name first, then by this marker.
|
|
236
252
|
|
|
237
253
|
Do not mix `--json` output with human-rendered output in the same run. Pick one mode and stick with it.
|
|
@@ -47,6 +47,9 @@ def test_install_writes_agent_doc_pointers(tmp_path: Path, monkeypatch) -> None:
|
|
|
47
47
|
assert main._AGENT_BLOCK_END in text
|
|
48
48
|
assert "assertion-cli" in text
|
|
49
49
|
assert "asrt prompt" in text
|
|
50
|
+
assert "do not call `asrt prompt` during Plan Mode" in text
|
|
51
|
+
assert "finalized plan" in text
|
|
52
|
+
assert "**Every user message:**" not in text
|
|
50
53
|
|
|
51
54
|
|
|
52
55
|
def test_install_preserves_existing_agent_doc_content(
|
|
@@ -132,6 +135,12 @@ def test_install_records_skill_cleanup_guidance(tmp_path: Path, monkeypatch) ->
|
|
|
132
135
|
skill = (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").read_text()
|
|
133
136
|
assert "First command sequence for a new work session" in skill
|
|
134
137
|
assert "`asrt session start`" in skill
|
|
138
|
+
assert "## Plan Mode boundary" in skill
|
|
139
|
+
assert (
|
|
140
|
+
"do not run `asrt prompt`, `asrt checkpoint`, `asrt verify`, or PR commands"
|
|
141
|
+
in skill
|
|
142
|
+
)
|
|
143
|
+
assert 'asrt prompt "<finalized plan text>"' in skill
|
|
135
144
|
assert "`asrt new`" not in skill
|
|
136
145
|
|
|
137
146
|
|
|
@@ -66,6 +66,26 @@ def test_session_start_does_not_write_skill_or_agent_files(
|
|
|
66
66
|
assert not (tmp_path / "AGENTS.md").exists()
|
|
67
67
|
|
|
68
68
|
|
|
69
|
+
def test_session_start_prints_non_plan_prompt_workflow(
|
|
70
|
+
tmp_path: Path, monkeypatch
|
|
71
|
+
) -> None:
|
|
72
|
+
_init_repo(tmp_path)
|
|
73
|
+
monkeypatch.chdir(tmp_path)
|
|
74
|
+
|
|
75
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
76
|
+
assert result.exit_code == 0, result.stderr
|
|
77
|
+
|
|
78
|
+
assert (
|
|
79
|
+
'in non-Plan mode, asrt prompt "<msg>" for each user turn'
|
|
80
|
+
in result.stdout
|
|
81
|
+
)
|
|
82
|
+
assert (
|
|
83
|
+
"when starting from a finalized plan, log it with asrt prompt"
|
|
84
|
+
in result.stdout
|
|
85
|
+
)
|
|
86
|
+
assert 'asrt prompt "<msg>" on every user turn' not in result.stdout
|
|
87
|
+
|
|
88
|
+
|
|
69
89
|
def test_session_start_resets_assertion_state_but_keeps_seed_trackable(
|
|
70
90
|
tmp_path: Path, monkeypatch
|
|
71
91
|
) -> None:
|
|
@@ -246,6 +266,7 @@ def test_session_start_records_merge_base_as_verify_base(tmp_path: Path, monkeyp
|
|
|
246
266
|
assert base_sha_path.exists()
|
|
247
267
|
assert base_sha_path.read_text().strip() == merge_base
|
|
248
268
|
assert merge_base != head_sha
|
|
269
|
+
assert f"merge-base with {upstream}" in result.stdout
|
|
249
270
|
|
|
250
271
|
|
|
251
272
|
def test_session_start_errors_when_repo_has_no_commits(
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
import main
|
|
10
|
+
from models import MetadataPayload, SessionStatus, StatusResponse, VerifyResponse
|
|
11
|
+
from session import (
|
|
12
|
+
compute_review_diff_hash,
|
|
13
|
+
read_review_diff_hash,
|
|
14
|
+
read_verify_last_status,
|
|
15
|
+
save_review_diff_hash,
|
|
16
|
+
save_verify_last_status,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
runner = CliRunner()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _write_session(assertion_dir: Path) -> None:
|
|
24
|
+
assertion_dir.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
(assertion_dir / "verify_base_sha").write_text("a" * 40 + "\n")
|
|
26
|
+
(assertion_dir / "prompts").write_text(
|
|
27
|
+
json.dumps({"ts": "2026-01-01T00:00:00Z", "text": "build it"}) + "\n"
|
|
28
|
+
)
|
|
29
|
+
(assertion_dir / "checkpoint").write_text("done\n")
|
|
30
|
+
(assertion_dir / "metadata.json").write_text(
|
|
31
|
+
MetadataPayload(session_id="sess-1", stack_id="stack-1").model_dump_json()
|
|
32
|
+
+ "\n"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@patch("main.AssertionClient")
|
|
37
|
+
@patch("main._build_verify_bundle")
|
|
38
|
+
@patch("main.load_existing_session")
|
|
39
|
+
def test_verify_reuses_unchanged_successful_diff(
|
|
40
|
+
mock_load_session,
|
|
41
|
+
mock_build_bundle,
|
|
42
|
+
MockClient,
|
|
43
|
+
tmp_path: Path,
|
|
44
|
+
monkeypatch,
|
|
45
|
+
) -> None:
|
|
46
|
+
assertion_dir = tmp_path / ".assertion"
|
|
47
|
+
_write_session(assertion_dir)
|
|
48
|
+
review_diff = "diff unchanged\n"
|
|
49
|
+
digest = compute_review_diff_hash(review_diff)
|
|
50
|
+
save_review_diff_hash(assertion_dir, digest)
|
|
51
|
+
save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
|
|
52
|
+
(assertion_dir / "verify_session_id").write_text("sess-1\n")
|
|
53
|
+
(assertion_dir / "link").write_text(
|
|
54
|
+
"https://app.tryassertion.com/sessions/sess-1\n"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
from session import SessionContext
|
|
58
|
+
|
|
59
|
+
ctx = SessionContext(
|
|
60
|
+
repo_root=tmp_path,
|
|
61
|
+
assertion_dir=assertion_dir,
|
|
62
|
+
prompts_path=assertion_dir / "prompts",
|
|
63
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
64
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
65
|
+
)
|
|
66
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
67
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
68
|
+
mock_build_bundle.return_value = (b"bundle", review_diff, digest)
|
|
69
|
+
|
|
70
|
+
monkeypatch.chdir(tmp_path)
|
|
71
|
+
result = runner.invoke(main.app, ["verify"])
|
|
72
|
+
|
|
73
|
+
assert result.exit_code == 0
|
|
74
|
+
assert "reusing session sess-1" in result.stdout
|
|
75
|
+
MockClient.return_value.verify.assert_not_called()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@patch("main.AssertionClient")
|
|
79
|
+
@patch("main._build_verify_bundle")
|
|
80
|
+
@patch("main.load_existing_session")
|
|
81
|
+
def test_verify_submits_when_diff_changed(
|
|
82
|
+
mock_load_session,
|
|
83
|
+
mock_build_bundle,
|
|
84
|
+
MockClient,
|
|
85
|
+
tmp_path: Path,
|
|
86
|
+
monkeypatch,
|
|
87
|
+
) -> None:
|
|
88
|
+
assertion_dir = tmp_path / ".assertion"
|
|
89
|
+
_write_session(assertion_dir)
|
|
90
|
+
old_digest = compute_review_diff_hash("old diff\n")
|
|
91
|
+
save_review_diff_hash(assertion_dir, old_digest)
|
|
92
|
+
save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
|
|
93
|
+
|
|
94
|
+
from session import SessionContext
|
|
95
|
+
|
|
96
|
+
ctx = SessionContext(
|
|
97
|
+
repo_root=tmp_path,
|
|
98
|
+
assertion_dir=assertion_dir,
|
|
99
|
+
prompts_path=assertion_dir / "prompts",
|
|
100
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
101
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
102
|
+
)
|
|
103
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
104
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
105
|
+
new_diff = "new diff\n"
|
|
106
|
+
mock_build_bundle.return_value = (
|
|
107
|
+
b"bundle",
|
|
108
|
+
new_diff,
|
|
109
|
+
compute_review_diff_hash(new_diff),
|
|
110
|
+
)
|
|
111
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
112
|
+
session_id="sess-1",
|
|
113
|
+
url="https://app.tryassertion.com/sessions/sess-1",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
monkeypatch.chdir(tmp_path)
|
|
117
|
+
result = runner.invoke(main.app, ["verify"])
|
|
118
|
+
|
|
119
|
+
assert result.exit_code == 0
|
|
120
|
+
assert "Verification submitted" in result.stdout
|
|
121
|
+
MockClient.return_value.verify.assert_called_once()
|
|
122
|
+
assert read_review_diff_hash(assertion_dir) == compute_review_diff_hash(new_diff)
|
|
123
|
+
assert read_verify_last_status(assertion_dir) == SessionStatus.RUNNING.value
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@patch("main.AssertionClient")
|
|
127
|
+
@patch("main._build_verify_bundle")
|
|
128
|
+
@patch("main.load_existing_session")
|
|
129
|
+
def test_verify_does_not_reuse_after_failure(
|
|
130
|
+
mock_load_session,
|
|
131
|
+
mock_build_bundle,
|
|
132
|
+
MockClient,
|
|
133
|
+
tmp_path: Path,
|
|
134
|
+
monkeypatch,
|
|
135
|
+
) -> None:
|
|
136
|
+
assertion_dir = tmp_path / ".assertion"
|
|
137
|
+
_write_session(assertion_dir)
|
|
138
|
+
review_diff = "same diff\n"
|
|
139
|
+
digest = compute_review_diff_hash(review_diff)
|
|
140
|
+
save_review_diff_hash(assertion_dir, digest)
|
|
141
|
+
save_verify_last_status(assertion_dir, SessionStatus.FAILED.value)
|
|
142
|
+
|
|
143
|
+
from session import SessionContext
|
|
144
|
+
|
|
145
|
+
ctx = SessionContext(
|
|
146
|
+
repo_root=tmp_path,
|
|
147
|
+
assertion_dir=assertion_dir,
|
|
148
|
+
prompts_path=assertion_dir / "prompts",
|
|
149
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
150
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
151
|
+
)
|
|
152
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
153
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
154
|
+
mock_build_bundle.return_value = (b"bundle", review_diff, digest)
|
|
155
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
156
|
+
session_id="sess-1",
|
|
157
|
+
url="https://app.tryassertion.com/sessions/sess-1",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
monkeypatch.chdir(tmp_path)
|
|
161
|
+
result = runner.invoke(main.app, ["verify"])
|
|
162
|
+
|
|
163
|
+
assert result.exit_code == 0
|
|
164
|
+
MockClient.return_value.verify.assert_called_once()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@patch("main.AssertionClient")
|
|
168
|
+
@patch("main._build_verify_bundle")
|
|
169
|
+
@patch("main.load_existing_session")
|
|
170
|
+
def test_verify_force_bypasses_reuse(
|
|
171
|
+
mock_load_session,
|
|
172
|
+
mock_build_bundle,
|
|
173
|
+
MockClient,
|
|
174
|
+
tmp_path: Path,
|
|
175
|
+
monkeypatch,
|
|
176
|
+
) -> None:
|
|
177
|
+
assertion_dir = tmp_path / ".assertion"
|
|
178
|
+
_write_session(assertion_dir)
|
|
179
|
+
review_diff = "same diff\n"
|
|
180
|
+
digest = compute_review_diff_hash(review_diff)
|
|
181
|
+
save_review_diff_hash(assertion_dir, digest)
|
|
182
|
+
save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
|
|
183
|
+
(assertion_dir / "verify_session_id").write_text("sess-1\n")
|
|
184
|
+
(assertion_dir / "link").write_text(
|
|
185
|
+
"https://app.tryassertion.com/sessions/sess-1\n"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
from session import SessionContext
|
|
189
|
+
|
|
190
|
+
ctx = SessionContext(
|
|
191
|
+
repo_root=tmp_path,
|
|
192
|
+
assertion_dir=assertion_dir,
|
|
193
|
+
prompts_path=assertion_dir / "prompts",
|
|
194
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
195
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
196
|
+
)
|
|
197
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
198
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
199
|
+
mock_build_bundle.return_value = (b"bundle", review_diff, digest)
|
|
200
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
201
|
+
session_id="sess-1",
|
|
202
|
+
url="https://app.tryassertion.com/sessions/sess-1",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
monkeypatch.chdir(tmp_path)
|
|
206
|
+
result = runner.invoke(main.app, ["verify", "--force"])
|
|
207
|
+
|
|
208
|
+
assert result.exit_code == 0
|
|
209
|
+
MockClient.return_value.verify.assert_called_once()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@patch("main.AssertionClient")
|
|
213
|
+
@patch("main._load_verify_session_id")
|
|
214
|
+
@patch("main.load_existing_session")
|
|
215
|
+
def test_verify_status_persists_terminal_status(
|
|
216
|
+
mock_load_session,
|
|
217
|
+
mock_load_session_id,
|
|
218
|
+
MockClient,
|
|
219
|
+
tmp_path: Path,
|
|
220
|
+
monkeypatch,
|
|
221
|
+
) -> None:
|
|
222
|
+
assertion_dir = tmp_path / ".assertion"
|
|
223
|
+
_write_session(assertion_dir)
|
|
224
|
+
|
|
225
|
+
from session import SessionContext
|
|
226
|
+
|
|
227
|
+
ctx = SessionContext(
|
|
228
|
+
repo_root=tmp_path,
|
|
229
|
+
assertion_dir=assertion_dir,
|
|
230
|
+
prompts_path=assertion_dir / "prompts",
|
|
231
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
232
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
233
|
+
)
|
|
234
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
235
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
236
|
+
mock_load_session_id.return_value = "sess-1"
|
|
237
|
+
MockClient.return_value.status.return_value = StatusResponse(
|
|
238
|
+
status=SessionStatus.SUCCEEDED,
|
|
239
|
+
message="ok",
|
|
240
|
+
url="https://app.tryassertion.com/sessions/sess-1",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
monkeypatch.chdir(tmp_path)
|
|
244
|
+
result = runner.invoke(main.app, ["verify-status"])
|
|
245
|
+
|
|
246
|
+
assert result.exit_code == 0
|
|
247
|
+
assert read_verify_last_status(assertion_dir) == SessionStatus.SUCCEEDED.value
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@patch("main.AssertionClient")
|
|
251
|
+
@patch("main._build_verify_bundle")
|
|
252
|
+
@patch("main.load_existing_session")
|
|
253
|
+
def test_verify_reuse_json_output(
|
|
254
|
+
mock_load_session,
|
|
255
|
+
mock_build_bundle,
|
|
256
|
+
MockClient,
|
|
257
|
+
tmp_path: Path,
|
|
258
|
+
monkeypatch,
|
|
259
|
+
) -> None:
|
|
260
|
+
assertion_dir = tmp_path / ".assertion"
|
|
261
|
+
_write_session(assertion_dir)
|
|
262
|
+
review_diff = "diff unchanged\n"
|
|
263
|
+
digest = compute_review_diff_hash(review_diff)
|
|
264
|
+
save_review_diff_hash(assertion_dir, digest)
|
|
265
|
+
save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
|
|
266
|
+
(assertion_dir / "verify_session_id").write_text("sess-1\n")
|
|
267
|
+
(assertion_dir / "link").write_text(
|
|
268
|
+
"https://app.tryassertion.com/sessions/sess-1\n"
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
from session import SessionContext
|
|
272
|
+
|
|
273
|
+
ctx = SessionContext(
|
|
274
|
+
repo_root=tmp_path,
|
|
275
|
+
assertion_dir=assertion_dir,
|
|
276
|
+
prompts_path=assertion_dir / "prompts",
|
|
277
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
278
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
279
|
+
)
|
|
280
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
281
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
282
|
+
mock_build_bundle.return_value = (b"bundle", review_diff, digest)
|
|
283
|
+
|
|
284
|
+
monkeypatch.chdir(tmp_path)
|
|
285
|
+
result = runner.invoke(main.app, ["verify", "--json"])
|
|
286
|
+
|
|
287
|
+
assert result.exit_code == 0
|
|
288
|
+
payload = json.loads(result.stdout)
|
|
289
|
+
assert payload["reused"] is True
|
|
290
|
+
assert payload["status"] == "succeeded"
|
|
291
|
+
MockClient.return_value.verify.assert_not_called()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
@patch("main.AssertionClient")
|
|
295
|
+
@patch("main._build_verify_bundle")
|
|
296
|
+
@patch("main.load_existing_session")
|
|
297
|
+
def test_verify_missing_session_id_falls_through_to_submit(
|
|
298
|
+
mock_load_session,
|
|
299
|
+
mock_build_bundle,
|
|
300
|
+
MockClient,
|
|
301
|
+
tmp_path: Path,
|
|
302
|
+
monkeypatch,
|
|
303
|
+
) -> None:
|
|
304
|
+
assertion_dir = tmp_path / ".assertion"
|
|
305
|
+
_write_session(assertion_dir)
|
|
306
|
+
review_diff = "same diff\n"
|
|
307
|
+
digest = compute_review_diff_hash(review_diff)
|
|
308
|
+
save_review_diff_hash(assertion_dir, digest)
|
|
309
|
+
save_verify_last_status(assertion_dir, SessionStatus.SUCCEEDED.value)
|
|
310
|
+
|
|
311
|
+
from session import SessionContext
|
|
312
|
+
|
|
313
|
+
ctx = SessionContext(
|
|
314
|
+
repo_root=tmp_path,
|
|
315
|
+
assertion_dir=assertion_dir,
|
|
316
|
+
prompts_path=assertion_dir / "prompts",
|
|
317
|
+
checkpoint_path=assertion_dir / "checkpoint",
|
|
318
|
+
metadata_path=assertion_dir / "metadata.json",
|
|
319
|
+
)
|
|
320
|
+
metadata = MetadataPayload(session_id="sess-1", stack_id="stack-1")
|
|
321
|
+
mock_load_session.return_value = (ctx, metadata)
|
|
322
|
+
mock_build_bundle.return_value = (b"bundle", review_diff, digest)
|
|
323
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
324
|
+
session_id="sess-1",
|
|
325
|
+
url="https://app.tryassertion.com/sessions/sess-1",
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
monkeypatch.chdir(tmp_path)
|
|
329
|
+
result = runner.invoke(main.app, ["verify"])
|
|
330
|
+
|
|
331
|
+
assert result.exit_code == 0
|
|
332
|
+
MockClient.return_value.verify.assert_called_once()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from typer.testing import CliRunner
|
|
10
|
+
|
|
11
|
+
import main
|
|
12
|
+
from git import format_verify_scope_label, get_closest_merged_remote_ref
|
|
13
|
+
from models import MetadataPayload, SessionStatus, VerifyResponse
|
|
14
|
+
from session import (
|
|
15
|
+
REVIEW_DIFF_HASH_FILE_NAME,
|
|
16
|
+
VERIFY_LAST_STATUS_FILE_NAME,
|
|
17
|
+
compute_review_diff_hash,
|
|
18
|
+
read_review_diff_hash,
|
|
19
|
+
read_verify_last_status,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
runner = CliRunner()
|
|
24
|
+
|
|
25
|
+
_GIT_ENV = {
|
|
26
|
+
"GIT_AUTHOR_NAME": "T",
|
|
27
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
28
|
+
"GIT_COMMITTER_NAME": "T",
|
|
29
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
30
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _run_git(repo: Path, *args: str) -> str:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", *args],
|
|
37
|
+
cwd=repo,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
check=True,
|
|
40
|
+
env={**_GIT_ENV, "HOME": str(repo)},
|
|
41
|
+
text=True,
|
|
42
|
+
)
|
|
43
|
+
return result.stdout.strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _setup_branching_repo(repo: Path) -> str:
|
|
47
|
+
_run_git(repo, "init")
|
|
48
|
+
_run_git(repo, "remote", "add", "origin", "http://example.com/repo.git")
|
|
49
|
+
_run_git(repo, "commit", "--allow-empty", "-m", "main")
|
|
50
|
+
main_sha = _run_git(repo, "rev-parse", "HEAD")
|
|
51
|
+
_run_git(repo, "update-ref", "refs/remotes/origin/main", main_sha)
|
|
52
|
+
|
|
53
|
+
_run_git(repo, "checkout", "-b", "feature-base")
|
|
54
|
+
_run_git(repo, "commit", "--allow-empty", "-m", "base work")
|
|
55
|
+
feature_base_sha = _run_git(repo, "rev-parse", "HEAD")
|
|
56
|
+
_run_git(repo, "update-ref", "refs/remotes/origin/feature-base", feature_base_sha)
|
|
57
|
+
|
|
58
|
+
_run_git(repo, "checkout", "-b", "feature-follow-up")
|
|
59
|
+
_run_git(repo, "commit", "--allow-empty", "-m", "follow-up")
|
|
60
|
+
return feature_base_sha
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _write_assertion_session(repo: Path) -> None:
|
|
64
|
+
assertion = repo / ".assertion"
|
|
65
|
+
assertion.mkdir()
|
|
66
|
+
base = _run_git(repo, "merge-base", "HEAD", "origin/feature-base")
|
|
67
|
+
(assertion / "verify_base_sha").write_text(base + "\n")
|
|
68
|
+
(assertion / "prompts").write_text(
|
|
69
|
+
json.dumps({"ts": "2026-01-01T00:00:00Z", "text": "do work"}) + "\n"
|
|
70
|
+
)
|
|
71
|
+
(assertion / "checkpoint").write_text("ready\n")
|
|
72
|
+
(assertion / "metadata.json").write_text(
|
|
73
|
+
MetadataPayload(session_id="sess-int", stack_id="stack-1").model_dump_json()
|
|
74
|
+
+ "\n"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_format_verify_scope_label_names_parent_branch(tmp_path: Path) -> None:
|
|
79
|
+
feature_base_sha = _setup_branching_repo(tmp_path)
|
|
80
|
+
upstream = _run_git(
|
|
81
|
+
tmp_path, "merge-base", "HEAD", "origin/main", "origin/feature-base"
|
|
82
|
+
)
|
|
83
|
+
assert upstream == feature_base_sha
|
|
84
|
+
assert get_closest_merged_remote_ref(tmp_path) == "origin/feature-base"
|
|
85
|
+
assert format_verify_scope_label(tmp_path, upstream) == (
|
|
86
|
+
"merge-base with origin/feature-base"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_format_verify_scope_label_keeps_named_upstream(tmp_path: Path) -> None:
|
|
91
|
+
assert (
|
|
92
|
+
format_verify_scope_label(tmp_path, "origin/main")
|
|
93
|
+
== "merge-base with origin/main"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@patch("main.AssertionClient")
|
|
98
|
+
def test_build_verify_bundle_uses_recorded_base(MockClient, tmp_path: Path) -> None:
|
|
99
|
+
feature_base_sha = _setup_branching_repo(tmp_path)
|
|
100
|
+
_write_assertion_session(tmp_path)
|
|
101
|
+
|
|
102
|
+
from session import SessionContext, _load_metadata
|
|
103
|
+
|
|
104
|
+
ctx = SessionContext(
|
|
105
|
+
repo_root=tmp_path,
|
|
106
|
+
assertion_dir=tmp_path / ".assertion",
|
|
107
|
+
prompts_path=tmp_path / ".assertion" / "prompts",
|
|
108
|
+
checkpoint_path=tmp_path / ".assertion" / "checkpoint",
|
|
109
|
+
metadata_path=tmp_path / ".assertion" / "metadata.json",
|
|
110
|
+
)
|
|
111
|
+
metadata = _load_metadata(ctx.metadata_path)
|
|
112
|
+
assert metadata is not None
|
|
113
|
+
|
|
114
|
+
bundle_bytes, review_diff, digest = main._build_verify_bundle(ctx, metadata)
|
|
115
|
+
assert digest == compute_review_diff_hash(review_diff)
|
|
116
|
+
assert feature_base_sha in review_diff or review_diff == ""
|
|
117
|
+
assert bundle_bytes.startswith(b"PK")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@patch("main.AssertionClient")
|
|
121
|
+
def test_verify_end_to_end_git_flow_submits_bundle(MockClient, tmp_path: Path, monkeypatch) -> None:
|
|
122
|
+
_setup_branching_repo(tmp_path)
|
|
123
|
+
_write_assertion_session(tmp_path)
|
|
124
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
125
|
+
session_id="sess-int",
|
|
126
|
+
url="https://app.tryassertion.com/sessions/sess-int",
|
|
127
|
+
)
|
|
128
|
+
monkeypatch.chdir(tmp_path)
|
|
129
|
+
|
|
130
|
+
result = runner.invoke(main.app, ["verify"])
|
|
131
|
+
assert result.exit_code == 0, result.stdout + result.stderr
|
|
132
|
+
assert "Verification submitted" in result.stdout
|
|
133
|
+
MockClient.return_value.verify.assert_called_once()
|
|
134
|
+
args, kwargs = MockClient.return_value.verify.call_args
|
|
135
|
+
assert kwargs["bundle_bytes"].startswith(b"PK")
|
|
136
|
+
assert read_review_diff_hash(tmp_path / ".assertion") is not None
|
|
137
|
+
assert read_verify_last_status(tmp_path / ".assertion") == SessionStatus.RUNNING.value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@patch("main.AssertionClient")
|
|
141
|
+
def test_verify_reuse_after_status_succeeded(MockClient, tmp_path: Path, monkeypatch) -> None:
|
|
142
|
+
_setup_branching_repo(tmp_path)
|
|
143
|
+
_write_assertion_session(tmp_path)
|
|
144
|
+
MockClient.return_value.verify.return_value = VerifyResponse(
|
|
145
|
+
session_id="sess-int",
|
|
146
|
+
url="https://app.tryassertion.com/sessions/sess-int",
|
|
147
|
+
)
|
|
148
|
+
monkeypatch.chdir(tmp_path)
|
|
149
|
+
|
|
150
|
+
first = runner.invoke(main.app, ["verify"])
|
|
151
|
+
assert first.exit_code == 0
|
|
152
|
+
(tmp_path / ".assertion" / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
|
|
153
|
+
|
|
154
|
+
second = runner.invoke(main.app, ["verify"])
|
|
155
|
+
assert second.exit_code == 0
|
|
156
|
+
assert "reusing session" in second.stdout
|
|
157
|
+
assert MockClient.return_value.verify.call_count == 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@patch("main.AssertionClient")
|
|
161
|
+
def test_verify_status_running_does_not_erase_succeeded(
|
|
162
|
+
MockClient, tmp_path: Path, monkeypatch
|
|
163
|
+
) -> None:
|
|
164
|
+
_setup_branching_repo(tmp_path)
|
|
165
|
+
_write_assertion_session(tmp_path)
|
|
166
|
+
assertion = tmp_path / ".assertion"
|
|
167
|
+
(assertion / "verify_session_id").write_text("sess-int\n")
|
|
168
|
+
(assertion / "link").write_text("https://app.tryassertion.com/sessions/sess-int\n")
|
|
169
|
+
(assertion / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
|
|
170
|
+
(assertion / REVIEW_DIFF_HASH_FILE_NAME).write_text("deadbeef\n")
|
|
171
|
+
|
|
172
|
+
from models import StatusResponse
|
|
173
|
+
|
|
174
|
+
MockClient.return_value.status.return_value = StatusResponse(
|
|
175
|
+
status=SessionStatus.RUNNING,
|
|
176
|
+
message="still going",
|
|
177
|
+
url="https://app.tryassertion.com/sessions/sess-int",
|
|
178
|
+
)
|
|
179
|
+
monkeypatch.chdir(tmp_path)
|
|
180
|
+
|
|
181
|
+
result = runner.invoke(main.app, ["verify-status"])
|
|
182
|
+
assert result.exit_code == 0
|
|
183
|
+
assert read_verify_last_status(assertion) == "succeeded"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_session_start_clears_verify_reuse_state(tmp_path: Path, monkeypatch) -> None:
|
|
187
|
+
_setup_branching_repo(tmp_path)
|
|
188
|
+
assertion = tmp_path / ".assertion"
|
|
189
|
+
assertion.mkdir()
|
|
190
|
+
(assertion / REVIEW_DIFF_HASH_FILE_NAME).write_text("hash\n")
|
|
191
|
+
(assertion / VERIFY_LAST_STATUS_FILE_NAME).write_text("succeeded\n")
|
|
192
|
+
(assertion / "old_state").write_text("x\n")
|
|
193
|
+
monkeypatch.chdir(tmp_path)
|
|
194
|
+
|
|
195
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
196
|
+
assert result.exit_code == 0
|
|
197
|
+
assert not (assertion / REVIEW_DIFF_HASH_FILE_NAME).exists()
|
|
198
|
+
assert not (assertion / VERIFY_LAST_STATUS_FILE_NAME).exists()
|
|
199
|
+
assert not (assertion / "old_state").exists()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@patch("main.AssertionClient")
|
|
203
|
+
@patch("session._require_repo_ready")
|
|
204
|
+
@patch("session._require_prompts_file")
|
|
205
|
+
def test_checkpoint_still_builds_bundle_after_verify_refactor(
|
|
206
|
+
mock_require_prompts,
|
|
207
|
+
mock_require_repo_ready,
|
|
208
|
+
MockClient,
|
|
209
|
+
tmp_path: Path,
|
|
210
|
+
monkeypatch,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Checkpoint path must remain independent of verify bundle helper."""
|
|
213
|
+
_setup_branching_repo(tmp_path)
|
|
214
|
+
_write_assertion_session(tmp_path)
|
|
215
|
+
mock_require_repo_ready.return_value = tmp_path
|
|
216
|
+
from models import CheckpointResponse, ReviewDetail
|
|
217
|
+
|
|
218
|
+
MockClient.return_value.checkpoint.return_value = CheckpointResponse(
|
|
219
|
+
checkpoint_id="cp-1",
|
|
220
|
+
outcome="succeeded",
|
|
221
|
+
reasoning="ok",
|
|
222
|
+
reviews=[ReviewDetail(model="m", approved=True, feedback="ok")],
|
|
223
|
+
created_at="2026-01-01T00:00:00+00:00",
|
|
224
|
+
updated_at="2026-01-01T00:00:00+00:00",
|
|
225
|
+
)
|
|
226
|
+
monkeypatch.chdir(tmp_path)
|
|
227
|
+
|
|
228
|
+
result = runner.invoke(main.app, ["checkpoint", "still works"])
|
|
229
|
+
assert result.exit_code == 0, result.stdout + result.stderr
|
|
230
|
+
MockClient.return_value.checkpoint.assert_called_once()
|
|
231
|
+
args, kwargs = MockClient.return_value.checkpoint.call_args
|
|
232
|
+
assert kwargs["bundle_bytes"].startswith(b"PK")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|