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.
Files changed (35) hide show
  1. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/PKG-INFO +2 -2
  2. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/README.md +1 -1
  3. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/PKG-INFO +2 -2
  4. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/SOURCES.txt +3 -1
  5. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/git.py +54 -0
  6. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/main.py +127 -27
  7. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/session.py +35 -0
  9. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/AGENTS.md +2 -2
  10. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/SKILL.md +25 -9
  11. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_install.py +9 -0
  12. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_session_start.py +21 -0
  13. assertion_cli-0.5.16/tests/test_verify.py +332 -0
  14. assertion_cli-0.5.16/tests/test_verify_integration.py +232 -0
  15. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/api.py +0 -0
  16. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/dependency_links.txt +0 -0
  17. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/entry_points.txt +0 -0
  18. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/requires.txt +0 -0
  19. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/assertion_cli.egg-info/top_level.txt +0 -0
  20. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/bundle.py +0 -0
  21. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/credentials.py +0 -0
  22. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/link.py +0 -0
  23. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/models.py +0 -0
  24. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/setup.cfg +0 -0
  25. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/templates/__init__.py +0 -0
  26. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_api.py +0 -0
  27. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_auth.py +0 -0
  28. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_bundle.py +0 -0
  29. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_credentials.py +0 -0
  30. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_decision.py +0 -0
  31. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_git.py +0 -0
  32. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_link.py +0 -0
  33. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_main.py +0 -0
  34. {assertion_cli-0.5.14 → assertion_cli-0.5.16}/tests/test_prompt.py +0 -0
  35. {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.14
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.14
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
 
@@ -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(
@@ -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>" on every user turn')
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
- verify_base = read_init_verify_base_sha(ctx.assertion_dir)
669
- clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
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
- bundle_bytes = build_bundle(
682
- metadata=metadata,
683
- diff_text=diff_text,
684
- review_diff_text=review_diff_text,
685
- prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
686
- checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
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:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.14"
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:
@@ -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 for every conversation:
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. **Every user message:** call `asrt prompt "<exact text of the message>"` before doing any other work for that turn. Apply to every user turn, not just the first.
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 every user message 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.
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. Immediately log the active user prompt with `asrt prompt`.
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 every user message, immediately
39
+ ## Rule 1 — Log non-Plan user messages and finalized plans, immediately
33
40
 
34
- **The first thing you do when a new user message arrives — before reading any file, planning, or editing anything — is run:**
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
- Exception: on the first 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 so the new/current session records the user's intent.
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
- 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").
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>" # every user turn, before anything else
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