assertion-cli 0.5.15__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.
Files changed (35) hide show
  1. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/PKG-INFO +2 -1
  2. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/README.md +1 -0
  3. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/PKG-INFO +2 -1
  4. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/SOURCES.txt +3 -1
  5. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/git.py +54 -0
  6. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/main.py +125 -26
  7. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/session.py +35 -0
  9. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/templates/SKILL.md +4 -1
  10. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_session_start.py +1 -0
  11. assertion_cli-0.5.16/tests/test_verify.py +332 -0
  12. assertion_cli-0.5.16/tests/test_verify_integration.py +232 -0
  13. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/api.py +0 -0
  14. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/dependency_links.txt +0 -0
  15. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/entry_points.txt +0 -0
  16. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/requires.txt +0 -0
  17. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/assertion_cli.egg-info/top_level.txt +0 -0
  18. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/bundle.py +0 -0
  19. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/credentials.py +0 -0
  20. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/link.py +0 -0
  21. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/models.py +0 -0
  22. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/setup.cfg +0 -0
  23. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/templates/AGENTS.md +0 -0
  24. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/templates/__init__.py +0 -0
  25. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_api.py +0 -0
  26. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_auth.py +0 -0
  27. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_bundle.py +0 -0
  28. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_credentials.py +0 -0
  29. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_decision.py +0 -0
  30. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_git.py +0 -0
  31. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_install.py +0 -0
  32. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_link.py +0 -0
  33. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_main.py +0 -0
  34. {assertion_cli-0.5.15 → assertion_cli-0.5.16}/tests/test_prompt.py +0 -0
  35. {assertion_cli-0.5.15 → 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.15
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
@@ -47,6 +47,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
48
48
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
49
49
  asrt verify # submit final verification (non-blocking)
50
+ asrt verify --force # submit even when review diff is unchanged
50
51
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
52
  ```
52
53
 
@@ -34,6 +34,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
34
34
  asrt checkpoint --continue "Implemented Y"
35
35
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
36
36
  asrt verify # submit final verification (non-blocking)
37
+ asrt verify --force # submit even when review diff is unchanged
37
38
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
38
39
  ```
39
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.15
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
@@ -47,6 +47,7 @@ asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
47
47
  asrt checkpoint --continue "Implemented Y"
48
48
  asrt decision --yes <checkpoint-id> # optional, only after a failed checkpoint
49
49
  asrt verify # submit final verification (non-blocking)
50
+ asrt verify --force # submit even when review diff is unchanged
50
51
  asrt verify-status # one-shot poll; loop with your own sleep until terminal
51
52
  ```
52
53
 
@@ -28,4 +28,6 @@ tests/test_link.py
28
28
  tests/test_main.py
29
29
  tests/test_prompt.py
30
30
  tests/test_session.py
31
- tests/test_session_start.py
31
+ tests/test_session_start.py
32
+ tests/test_verify.py
33
+ tests/test_verify_integration.py
@@ -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 _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
364
- """Resolve the verification base SHA.
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
- return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
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 = _resolve_verify_base(repo_root, from_ref)
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]} {verify_base_path.relative_to(repo_root)}"
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(
@@ -603,6 +614,14 @@ def _save_verify_session_id(assertion_dir: Path, session_id: str) -> None:
603
614
  )
604
615
 
605
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
+
606
625
  def _load_verify_session_id(assertion_dir: Path) -> str:
607
626
  path = assertion_dir / VERIFY_SESSION_FILE_NAME
608
627
  if not path.exists():
@@ -648,6 +667,82 @@ def _write_screenshots(
648
667
  return screenshot_dir
649
668
 
650
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
+
651
746
  @app.command("verify")
652
747
  def verify(
653
748
  json_output: bool = typer.Option(
@@ -655,6 +750,11 @@ def verify(
655
750
  "--json",
656
751
  help="Print the verification submission as a single JSON line.",
657
752
  ),
753
+ force: bool = typer.Option(
754
+ False,
755
+ "--force",
756
+ help="Submit a new verification even when the review diff is unchanged.",
757
+ ),
658
758
  ) -> None:
659
759
  """Submit final verification. Returns immediately without polling.
660
760
 
@@ -662,30 +762,24 @@ def verify(
662
762
  shell long enough to trip a Bash-tool timeout was a real failure mode. So
663
763
  this command only submits — poll `asrt verify-status` (one-shot) on whatever
664
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.
665
769
  """
666
770
  ctx, metadata = load_existing_session(Path.cwd())
667
771
  stack_id = metadata.stack_id
668
772
  assert stack_id is not None
669
- verify_base = read_init_verify_base_sha(ctx.assertion_dir)
670
- clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
671
- head_branch = get_head_branch(ctx.repo_root)
672
- diff_text = get_full_diff(ctx.repo_root, clone_anchor)
673
- review_diff_text = get_full_diff(ctx.repo_root, verify_base)
674
- metadata = update_metadata_anchor(
675
- ctx.metadata_path,
676
- metadata,
677
- verify_base,
678
- clone_sha=clone_anchor,
679
- head_branch=head_branch,
773
+ bundle_bytes, review_diff_text, review_diff_hash = _build_verify_bundle(
774
+ ctx, metadata
680
775
  )
681
776
 
682
- bundle_bytes = build_bundle(
683
- metadata=metadata,
684
- diff_text=diff_text,
685
- review_diff_text=review_diff_text,
686
- prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
687
- checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
688
- )
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
689
783
 
690
784
  client = AssertionClient()
691
785
  resp = client.verify(
@@ -698,6 +792,8 @@ def verify(
698
792
  session_url = resp.url
699
793
  save_link(ctx.assertion_dir, session_url)
700
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)
701
797
 
702
798
  if json_output:
703
799
  typer.echo(
@@ -706,6 +802,7 @@ def verify(
706
802
  "session_id": session_id,
707
803
  "url": session_url,
708
804
  "status": "submitted",
805
+ "reused": False,
709
806
  }
710
807
  )
711
808
  )
@@ -748,6 +845,8 @@ def verify_status(
748
845
  session_url = None
749
846
 
750
847
  terminal = payload.status in {SessionStatus.SUCCEEDED, SessionStatus.FAILED}
848
+ if terminal:
849
+ save_verify_last_status(ctx.assertion_dir, str(payload.status))
751
850
  screenshot_count = len(payload.screenshots)
752
851
  screenshots_saved = False
753
852
  if terminal and payload.screenshots:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.15"
7
+ version = "0.5.16"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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:
@@ -144,6 +144,8 @@ Do not call `verify` mid-task. It is not a substitute for checkpoints; it is the
144
144
 
145
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.
146
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
+
147
149
  ```
148
150
  asrt verify # submit; prints session id + URL, exits 0
149
151
  # … then poll until terminal:
@@ -227,6 +229,7 @@ asrt checkpoint --stack <id> "..." # first checkpoint of a session
227
229
  asrt checkpoint "..." # subsequent checkpoints — auto-continues
228
230
  asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
229
231
  asrt verify # submit verification (non-blocking)
232
+ asrt verify --force # submit even when review diff unchanged
230
233
  asrt verify-status # one-shot poll; loop with your own sleep
231
234
  asrt get-link # hidden PR marker — embed in the PR body (required)
232
235
  ```
@@ -244,7 +247,7 @@ When running under a non-interactive harness (CI, benchmark runner, scripts):
244
247
  - Apply changes targeted at the cited failure.
245
248
  - Re-run `asrt checkpoint "<what you changed>" --json` (auto-continues the existing session).
246
249
  - Bound retries: stop after 3 attempts on the same failure mode, or when the same `reasoning` is returned twice in a row.
247
- 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.
248
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.
249
252
 
250
253
  Do not mix `--json` output with human-rendered output in the same run. Pick one mode and stick with it.
@@ -266,6 +266,7 @@ def test_session_start_records_merge_base_as_verify_base(tmp_path: Path, monkeyp
266
266
  assert base_sha_path.exists()
267
267
  assert base_sha_path.read_text().strip() == merge_base
268
268
  assert merge_base != head_sha
269
+ assert f"merge-base with {upstream}" in result.stdout
269
270
 
270
271
 
271
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