assertion-cli 0.2.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1,15 @@
1
+ api.py,sha256=6RuyOoZrsBTY_51XA60iHwkYJ6nqME0CrmDgGaEbWzk,6901
2
+ bundle.py,sha256=O616zO-SY66J0v2d1NAr3S7cshSmUIAUiYX_sI_dMYo,1515
3
+ git.py,sha256=nxIdwy4i9pQ-40BnFO4_sLOdQK3zUHsYG17cmXZj8uE,4010
4
+ link.py,sha256=bfH0MhkeTFGbOSJbSvuuw3ilGX3akyvjvCw25AP_fz8,643
5
+ main.py,sha256=igQOML1A6WlSFD8SWM5P7WxnuwsWxMMvwp7knxj4DpA,20759
6
+ models.py,sha256=ApnfCO7CF5Nfdy5-ho7U-YRLWM-C3421fAT7GXa4Y30,2269
7
+ session.py,sha256=3oe3GbAaA2ynjk-2DFsLvwlFUMwHNnUmTKkBrQo_8IE,6507
8
+ assertion_cli_templates/ACTIVATION.md,sha256=5Z63TiInCe_vJEhOkqiKb1S4LRJHKy4Wx-c5yUb8p8M,1608
9
+ assertion_cli_templates/SKILL.md,sha256=zE1QB-vRIzqxHAvCZI1VsE0S2fb7cWb_ynPwzATg9no,13260
10
+ assertion_cli_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ assertion_cli-0.4.0.dist-info/METADATA,sha256=y8Zo-m7oxj7RNdptPB5Utll143tbUbbZzzAIUeccnTk,1712
12
+ assertion_cli-0.4.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
+ assertion_cli-0.4.0.dist-info/entry_points.txt,sha256=LgwuPLZEIk-4sD7ghGVRQOhm3PG8sPVozMRxrihwMgU,34
14
+ assertion_cli-0.4.0.dist-info/top_level.txt,sha256=xbZYH9xQOa99z-Vbkc2Xp6h8oUR97DVlyczy8H4ZuFc,64
15
+ assertion_cli-0.4.0.dist-info/RECORD,,
@@ -8,7 +8,7 @@ This repo uses the Assertion CLI (`asrt`) to track user intent and verify your w
8
8
  Both files are byte-identical. Read whichever your agent loads (or either one directly). In brief, follow these three rules for every conversation:
9
9
 
10
10
  1. **Every user message:** call `asrt prompt "<exact text of the message>"` before doing any other work for that turn. This is the verifier's source of truth for what the user asked for and how their direction evolves. Apply to every user turn, not just the first.
11
- 2. **Checkpoint at trajectory-feedback moments**, not on every small change. Use `asrt checkpoint "<summary>"` the CLI auto-resolves the stack from this repo's GitHub origin on the first call and auto-continues the session on subsequent calls. A whole feature build is a handful of checkpoints, not one per file.
11
+ 2. **Checkpoint at trajectory-feedback moments**, not on every small change. On the first checkpoint of a session, run `asrt stacks`, pick the stack that fits this repo and the work (use the stack's `repo`, `name`, `description`, plus folder/branch/README signals), then run `asrt checkpoint --stack <id> "<summary>"`. Subsequent checkpoints in the same session don't need `--stack` — `asrt checkpoint "<summary>"` auto-continues. A whole feature build is a handful of checkpoints, not one per file.
12
12
  3. **Verify at completion** with `asrt verify`. If feedback requires changes, iterate and re-verify — do **not** drop back into the checkpoint loop. Once verify is clean, open a PR with `gh pr create` and include the session URL (from `asrt get-link`) in the body.
13
13
 
14
14
  Do not open a PR without a clean `asrt verify`.
@@ -14,11 +14,11 @@ This skill is operational, not advisory. Treat each rule as a hard requirement u
14
14
  Before any `asrt` call:
15
15
 
16
16
  - You are inside a git repository.
17
- - The repo has at least one remote-tracking ref that shares history with `HEAD` (the CLI uses the latest such commit as the diff base for checkpoint/verify; local-only commits on top flow through as part of the diff). `asrt prompt` does not require this.
17
+ - `asrt init` has been run in this repo. It records the current HEAD as the diff base that every checkpoint/verify diffs against, and installs the skill files you are reading right now. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt init` and retry.
18
18
 
19
- No `asrt init` is needed — the CLI creates `.assertion/` on first use. Treat `.assertion/metadata.json` and `.assertion/prompts` 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.
19
+ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/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.
20
20
 
21
- `asrt stacks` is read-only and lists available verification stack IDs. Run it once per session before your first checkpoint so you can pick a stack ID; do not hardcode IDs from memory.
21
+ `asrt stacks` is read-only and lists every verification stack in the user's workspace, along with the repo each is configured for. Run it once per session before your first checkpoint so you can pick a stack ID; do not hardcode IDs from memory.
22
22
 
23
23
  ---
24
24
 
@@ -59,20 +59,20 @@ This applies to **every user turn**, not just the first. It applies even when th
59
59
 
60
60
  **Form:**
61
61
 
62
- Every checkpoint:
62
+ The first checkpoint of a session needs a stack ID; subsequent ones don't:
63
+
63
64
  ```
64
- asrt checkpoint "<what you built and what you decided>"
65
+ asrt checkpoint --stack <STACK_ID> "<what you built and decided>" # first checkpoint
66
+ asrt checkpoint "<what you built and decided>" # 2nd+ checkpoints
65
67
  ```
66
68
 
67
- You don't need a flag to distinguish "first" from "subsequent" — the CLI does the right thing:
68
- - No session exists yet → start a new one. The CLI picks the stack by matching the repo's GitHub `owner/name` (from `git remote get-url origin`) against the stacks attached to this repo.
69
- - A session already exists → continue it.
70
-
71
- If no stack is attached to this repo on the very first checkpoint, the command fails with a clear message telling the user to attach one in the Assertion web app. If multiple stacks are attached, it tells you to pass `--stack <id>` to disambiguate.
69
+ How the CLI decides:
70
+ - `.assertion/metadata.json` doesn't exist yet → no session. You must pass `--stack <id>`. The CLI errors with a clear message if you don't.
71
+ - A session already exists → continue it. Don't pass `--stack` (it would overwrite the in-flight session).
72
72
 
73
- Flags exist for explicit intent, but you normally don't need them:
73
+ Flags exist for explicit intent:
74
74
  - `--continue` — fail fast if no session exists (otherwise behaves identically to no flag when a session does exist).
75
- - `--stack <id>` — force a brand-new session against a specific stack, overwriting any existing one. Only pass this if you deliberately want to abandon the current session.
75
+ - `--stack <id>` — anchor the new session to a specific stack. **Required on the first checkpoint, rejected once a session exists.** The stack is locked at session creation; re-anchoring would create a split verification record, so the CLI errors if you try. Only the user can reset a session (by clearing `.assertion/`).
76
76
 
77
77
  Use checkpoint messages to summarize substantive progress and decisions since the previous checkpoint — not a changelog of every line touched.
78
78
 
@@ -82,41 +82,31 @@ Use checkpoint messages to summarize substantive progress and decisions since th
82
82
  - If failed: treat the feedback as authoritative for the next slice of work, address it, then checkpoint again. Do not push toward verify with failing feedback unaddressed.
83
83
  - Optionally, after a failed checkpoint, record your sentiment with `asrt decision --yes <checkpoint-id>` (you agree with the feedback) or `asrt decision --no <checkpoint-id>` (you disagree). Do not call `decision` for successful checkpoints.
84
84
 
85
- ### Stack selection — handled by the CLI
86
-
87
- Stack selection is **enforced by the CLI**, not by you. When you run `asrt checkpoint "..."` (no `--stack` flag), the CLI:
88
-
89
- 1. Runs `git remote get-url origin` and normalizes to `owner/name`
90
- 2. Calls the backend, fetches every stack in the user's workspace, and matches by the stack's `repo` field
91
- 3. Picks the single match → starts the session against it
92
- 4. If zero or multiple match, exits non-zero with a clear, user-facing error
85
+ ### Stack selection — your judgment, not the CLI's
93
86
 
94
- You don't need to do the matching yourself or pass `--stack`. Just call `asrt checkpoint "..."`.
87
+ **You pick the stack.** Before the first checkpoint, run `asrt stacks` to see every stack in the user's workspace. Each line includes the stack's `id`, `name`, `description`, and the `repo` it's configured for. Pick the one that best fits this repo and the work in flight, then pass it as `--stack <id>` on the first checkpoint.
95
88
 
96
- ### When `asrt checkpoint` fails because no stack is available
97
-
98
- This is the most common failure. The CLI distinguishes three cases — your response is the same shape in all of them:
99
-
100
- | CLI error starts with | Meaning |
101
- |---|---|
102
- | `No verification stacks exist in this workspace yet.` | User has never set up any stacks. |
103
- | `No verification stack is attached to this repo (...)` | Stacks exist but none are bound to this repo. |
104
- | `Multiple stacks are attached to ...` | Two or more stacks match; CLI asks for `--stack <id>`. |
89
+ Signals to use, in roughly decreasing order of strength:
105
90
 
106
- **For the first two:** the user has to take an action in the Assertion web app. Your response:
91
+ 1. **Stack `repo` field vs. this repo's identity.** A stack whose `repo` matches the local checkout (folder name, `git remote -v` if present, `git config --get remote.origin.url`, the `name` in `package.json` / `pyproject.toml` / `Cargo.toml`, the top heading in `README.md`) is almost always the right choice.
92
+ 2. **Stack `name` / `description` vs. the work.** When more than one stack could plausibly fit the repo, pick the one whose name/description best matches the primary deliverable (e.g., "PR Code Quality" for refactors, "Security Review" for auth/permissions work, "Docs Audit" for documentation passes). The user's prompt log is your guide to what they actually asked for.
93
+ 3. **Branch name.** A branch named `feat/billing-…` against a stack named `Billing QA` is a strong signal.
107
94
 
108
- 1. Tell the user the error verbatim. The CLI already wrote the right message.
109
- 2. **Keep the work moving on your side.** You can continue editing files and implementing what the user asked for. Verification is the gate before opening a PR — not a gate on writing code.
110
- 3. Keep calling `asrt prompt "..."` on every new user turn. The prompt log accumulates in `.assertion/prompts` and is *not* lost when checkpoint fails — once the user attaches a stack, the next successful `asrt checkpoint "..."` ships every prompt logged so far in its bundle.
111
- 4. When the user says they've attached a stack (or asks you to retry), call `asrt checkpoint "<what you built so far>"` again. The CLI will resolve the stack this time and start the session with the full prompt history already in the bundle.
95
+ If none of the stacks plausibly fit this repo, surface that to the user verbatim — do not guess. They likely need to attach a stack in the Assertion web app first. Keep recording prompts; the prompt log persists and ships in the bundle once the user picks a stack and you successfully checkpoint.
112
96
 
113
- **Do not** try to work around the missing stack by guessing a stack ID, hardcoding one from another repo, or picking the wrong one with `--stack`. Repo-binding is the user's signal that the stack is configured for this codebase; bypassing it creates a verification session in the wrong context.
97
+ When you pass `--stack`, briefly state which stack you chose and why ("Picked stack `abc123` its `repo` matches and the description fits the auth work the user described"). That gives the user one chance to redirect before the session is anchored.
114
98
 
115
- **For the third case (multiple matches):** the CLI's error lists the candidates. Pick the one whose name/description best fits the primary deliverable (code review, security, docs, perf) and pass `asrt checkpoint --stack <id> "..."`. State your choice briefly so the user can correct you.
99
+ **Never** hardcode a stack ID from memory, copy one from a different repo, or pick one whose `repo` field clearly points elsewhere just to get a session started. A wrong stack creates a verification record in the wrong context, which is worse than no session at all.
116
100
 
117
- ### Optional: confirm before you start
101
+ ### When `asrt checkpoint` errors
118
102
 
119
- If you want to know which stack the CLI *would* pick before running checkpoint (e.g., the work is about to be long and you want to fail-fast on configuration), call `asrt stacks` and look for the line with `[repo: <current-repo-owner/name>]`. You don't have to — `asrt checkpoint` does this internally — but it's available as a read-only check.
103
+ | CLI error starts with | What it means | What to do |
104
+ |---|---|---|
105
+ | `No session exists yet and no --stack given.` | First checkpoint of a fresh `.assertion/`, but you didn't pick a stack. | Run `asrt stacks`, pick one using the signals above, pass `--stack <id>`. |
106
+ | `This session is already anchored to a stack.` | You passed `--stack` after a session was already created. The CLI locks the stack at session creation. | Drop the `--stack` flag and re-run `asrt checkpoint "<message>"`. |
107
+ | `ERROR: Unknown stack '<id>'.` | The `--stack` you passed is not in the workspace. | Re-run `asrt stacks` (the list may have changed) and pass a valid id. |
108
+ | `No diff base recorded for this repo.` | `asrt init` wasn't run. | Run `asrt init` and retry. |
109
+ | `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
120
110
 
121
111
  ---
122
112
 
@@ -164,13 +154,14 @@ done
164
154
  ## Quick reference
165
155
 
166
156
  ```
167
- asrt prompt "<message>" # every user turn, before anything else
168
- asrt stacks # optional: list stacks attached to this repo
169
- asrt checkpoint "..." # at trajectory moments auto-starts or continues
170
- asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
171
- asrt verify # submit verification (non-blocking)
172
- asrt verify-status # one-shot poll; loop with your own sleep
173
- asrt get-link # retrieve the last session URL
157
+ asrt prompt "<message>" # every user turn, before anything else
158
+ asrt stacks # list every stack read once before first checkpoint
159
+ asrt checkpoint --stack <id> "..." # first checkpoint of a session
160
+ asrt checkpoint "..." # subsequent checkpoints auto-continues
161
+ asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
162
+ asrt verify # submit verification (non-blocking)
163
+ asrt verify-status # one-shot poll; loop with your own sleep
164
+ asrt get-link # retrieve the last session URL
174
165
  ```
175
166
 
176
167
  ## Environment
bundle.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import io
2
+ import json
2
3
  import zipfile
3
4
 
4
5
  from models import MetadataPayload
@@ -10,6 +11,21 @@ PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
10
11
  CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
11
12
 
12
13
 
14
+ def _metadata_wire_json(metadata: MetadataPayload) -> str:
15
+ """Serialize metadata for the bundle, remapping `base_sha` → `head_sha`.
16
+
17
+ The CLI stores the diff base as `base_sha` because that matches what the
18
+ field means from the CLI's perspective (the foundation the diff builds
19
+ on). The backend reads it as `head_sha` because after its `git checkout`
20
+ that SHA literally is the clone's HEAD. Same value, different name per
21
+ side; the remap lives here so neither side has to know about the other's
22
+ perspective.
23
+ """
24
+ payload = metadata.model_dump()
25
+ payload["head_sha"] = payload.pop("base_sha", None)
26
+ return json.dumps(payload, indent=2) + "\n"
27
+
28
+
13
29
  def build_bundle(
14
30
  *,
15
31
  metadata: MetadataPayload,
@@ -19,7 +35,7 @@ def build_bundle(
19
35
  ) -> bytes:
20
36
  buf = io.BytesIO()
21
37
  with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
22
- zf.writestr(METADATA_ARCHIVE_PATH, metadata.model_dump_json(indent=2) + "\n")
38
+ zf.writestr(METADATA_ARCHIVE_PATH, _metadata_wire_json(metadata))
23
39
  zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
24
40
  zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
25
41
  zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
git.py CHANGED
@@ -37,73 +37,6 @@ def find_git_root(start_path: Path) -> Path:
37
37
  return Path(completed.stdout.strip())
38
38
 
39
39
 
40
- def get_diff_base_sha(repo_root: Path) -> str:
41
- """Return the SHA the verifier should diff against — i.e. the most recent
42
- commit reachable from BOTH local HEAD and some `refs/remotes/*` ref.
43
-
44
- The verifier clones from GitHub and runs `git checkout <head_sha>` (see
45
- backend/repo_analysis/clone.py). That checkout fails for any commit that
46
- only exists locally — which is exactly what Codex Cloud produces when its
47
- agent autocommits before `asrt checkpoint`. By picking the latest commit
48
- that's on origin AND reachable from HEAD, the checkout always succeeds;
49
- any local-only commits on top of that base flow through as part of the
50
- diff (see `get_uncommitted_diff`), so the verifier still sees the full
51
- state the agent built.
52
-
53
- When HEAD itself is on a remote-tracking ref (no local commits),
54
- `git merge-base HEAD <ref>` returns HEAD, so this collapses to the old
55
- behavior with no diff growth.
56
- """
57
- try:
58
- remote_refs = run_git_command(
59
- repo_root, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]
60
- )
61
- except RuntimeError as exc:
62
- exit_with_error(f"Failed to inspect remote refs: {exc}")
63
-
64
- refs = [
65
- ref for ref in remote_refs.splitlines() if ref and not ref.endswith("/HEAD")
66
- ]
67
- if not refs:
68
- exit_with_error(
69
- "No remote-tracking branches found. The verifier needs a commit "
70
- "on origin to apply the diff onto — push at least one branch "
71
- "(and `git fetch`) before running this command."
72
- )
73
-
74
- candidate_shas: list[str] = []
75
- for ref in refs:
76
- completed = subprocess.run(
77
- ["git", "merge-base", "HEAD", ref],
78
- cwd=repo_root,
79
- capture_output=True,
80
- text=True,
81
- check=False,
82
- )
83
- if completed.returncode == 0:
84
- sha = completed.stdout.strip()
85
- if sha:
86
- candidate_shas.append(sha)
87
-
88
- if not candidate_shas:
89
- exit_with_error(
90
- "HEAD has no commits in common with any remote-tracking branch. "
91
- "Push the branch (or its parent commits) to origin and retry."
92
- )
93
-
94
- best_sha = candidate_shas[0]
95
- best_ts = -1
96
- for sha in candidate_shas:
97
- try:
98
- ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", sha]))
99
- except (RuntimeError, ValueError):
100
- continue
101
- if ts > best_ts:
102
- best_ts = ts
103
- best_sha = sha
104
- return best_sha
105
-
106
-
107
40
  def get_head_sha(repo_root: Path) -> str:
108
41
  try:
109
42
  return run_git_command(repo_root, ["rev-parse", "HEAD"])
@@ -120,40 +53,6 @@ def get_head_branch(repo_root: Path) -> str | None:
120
53
  return name if name and name != "HEAD" else None
121
54
 
122
55
 
123
- def get_origin_github_repo(repo_root: Path) -> str | None:
124
- """Return the current repo's GitHub `owner/name` from origin, or None if not parseable.
125
-
126
- Accepts the common remote URL forms:
127
- git@github.com:owner/name(.git)?
128
- https://github.com/owner/name(.git)?
129
- ssh://git@github.com/owner/name(.git)?
130
- """
131
- try:
132
- url = run_git_command(repo_root, ["remote", "get-url", "origin"]).strip()
133
- except RuntimeError:
134
- return None
135
- if not url:
136
- return None
137
-
138
- if url.startswith("git@github.com:"):
139
- path = url[len("git@github.com:") :]
140
- elif url.startswith("ssh://git@github.com/"):
141
- path = url[len("ssh://git@github.com/") :]
142
- elif url.startswith("https://github.com/"):
143
- path = url[len("https://github.com/") :]
144
- elif url.startswith("http://github.com/"):
145
- path = url[len("http://github.com/") :]
146
- else:
147
- return None
148
-
149
- path = path.rstrip("/")
150
- if path.endswith(".git"):
151
- path = path[: -len(".git")]
152
- if path.count("/") != 1 or not all(path.split("/")):
153
- return None
154
- return path
155
-
156
-
157
56
  def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
158
57
  completed = subprocess.run(
159
58
  [
main.py CHANGED
@@ -5,6 +5,7 @@ import re
5
5
  from datetime import datetime, timezone
6
6
  from pathlib import Path
7
7
 
8
+ import httpx
8
9
  import typer
9
10
 
10
11
  from api import AssertionClient
@@ -12,21 +13,23 @@ from bundle import build_bundle
12
13
  from git import (
13
14
  exit_with_error,
14
15
  find_git_root,
15
- get_diff_base_sha,
16
16
  get_head_branch,
17
+ get_head_sha,
17
18
  get_uncommitted_diff,
18
19
  )
19
20
  from link import load_link, save_link
20
21
  from models import CheckpointResponse, SessionStatus, render_stack_list
21
22
  from session import (
22
23
  ASSERTION_DIR_NAME,
24
+ BASE_SHA_FILE_NAME,
23
25
  METADATA_FILE_NAME,
24
26
  PROMPTS_FILE_NAME,
25
27
  append_checkpoint_entry,
26
28
  continue_session,
27
29
  load_existing_session,
30
+ read_init_base_sha,
28
31
  start_new_session,
29
- update_metadata_head,
32
+ update_metadata_anchor,
30
33
  )
31
34
 
32
35
  app = typer.Typer(help="Assertion CLI")
@@ -194,11 +197,26 @@ def init() -> None:
194
197
  the skill and knows to call `asrt`. `asrt checkpoint` refreshes the skill files on each
195
198
  run so they stay aligned with the installed CLI; `CLAUDE.md` / `AGENTS.md` are only
196
199
  touched here, never by checkpoint, so they're safe to track in git.
200
+
201
+ Also records the current HEAD SHA as the diff base for all future checkpoint/verify
202
+ calls in this repo. The base is what the backend `git checkout`s against before
203
+ applying the diff — capturing it once at init means later commands don't need to
204
+ rediscover it (which previously broke in sandboxes with no remote-tracking refs).
197
205
  """
198
206
  repo_root = find_git_root(Path.cwd())
199
207
  _refresh_skill_files(repo_root)
200
208
  _apply_activation_blocks(repo_root)
201
209
  _ensure_gitignore_excludes_assertion(repo_root)
210
+
211
+ base_sha = get_head_sha(repo_root)
212
+ assertion_dir = repo_root / ASSERTION_DIR_NAME
213
+ assertion_dir.mkdir(exist_ok=True)
214
+ base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
215
+ base_sha_path.write_text(base_sha + "\n", encoding="utf-8")
216
+ typer.echo(
217
+ f"Recorded diff base {base_sha[:12]} → {base_sha_path.relative_to(repo_root)}"
218
+ )
219
+
202
220
  typer.echo("")
203
221
  typer.echo("The coding agent will now follow the Assertion workflow:")
204
222
  typer.echo(' - asrt prompt "<msg>" on every user turn')
@@ -234,7 +252,7 @@ def checkpoint(
234
252
  stack: str | None = typer.Option(
235
253
  None,
236
254
  "--stack",
237
- help="Force a new session against this stack ID. Overwrites any existing session.",
255
+ help="Stack ID for a new session. Required on the first checkpoint; rejected once a session exists.",
238
256
  ),
239
257
  continue_session_flag: bool = typer.Option(
240
258
  False,
@@ -247,11 +265,12 @@ def checkpoint(
247
265
  ) -> None:
248
266
  """Record a checkpoint.
249
267
 
250
- With no flag, the CLI auto-continues the existing session if one is present,
251
- otherwise starts a new session against the stack attached to this repo's
252
- GitHub origin. Use `--continue` to fail fast when no session exists, or
253
- `--stack <id>` to force a brand-new session (destructive replaces any
254
- in-flight session).
268
+ First checkpoint of a session requires `--stack <id>` the agent picks
269
+ the stack from `asrt stacks` using its own judgment (folder name, branch,
270
+ code, README, etc.). Once a session exists, the stack is locked: pass no
271
+ flag (auto-continues) or `--continue` (fails fast if no session exists).
272
+ Passing `--stack` on a later checkpoint is rejected — sessions cannot be
273
+ re-anchored mid-flight.
255
274
  """
256
275
  if stack and continue_session_flag:
257
276
  typer.echo("ERROR: --stack and --continue are mutually exclusive.", err=True)
@@ -266,35 +285,43 @@ def checkpoint(
266
285
  _refresh_skill_files(repo_root)
267
286
 
268
287
  existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
288
+ session_exists = existing_metadata_path.exists()
269
289
 
270
- if continue_session_flag or (stack is None and existing_metadata_path.exists()):
271
- # Either the caller asked to continue explicitly, OR they passed no
272
- # flag and a session already exists (2nd+ checkpoint of the same
273
- # session). In both cases continue rather than starting fresh — the
274
- # alternative would silently destroy an in-flight session. To force a
275
- # new session, pass --stack <id> or clear .assertion/ first (the
276
- # post-verify prompt offers to do this).
290
+ if stack and session_exists:
291
+ # The stack is locked once a session is anchored to it. The agent
292
+ # should never re-anchor a wrong-stack mid-session creates a split
293
+ # verification record. The user is the only one who resets sessions.
294
+ exit_with_error(
295
+ "This session is already anchored to a stack. Sessions cannot be "
296
+ "re-anchored mid-flight drop the `--stack` flag and re-run "
297
+ '`asrt checkpoint "<message>"` to continue. The anchored stack '
298
+ "is recorded in .assertion/metadata.json."
299
+ )
300
+
301
+ if continue_session_flag or session_exists:
277
302
  ctx, metadata = continue_session(start_path=Path.cwd())
278
303
  else:
279
- from session import resolve_stack_id_for_repo
280
-
281
- resolved_stack = stack or resolve_stack_id_for_repo(repo_root)
282
- ctx, metadata = start_new_session(
283
- start_path=Path.cwd(), stack_id=resolved_stack
284
- )
304
+ if stack is None:
305
+ exit_with_error(
306
+ "No session exists yet and no --stack given. Run `asrt stacks` "
307
+ "to see available stacks, pick one whose name/description fits "
308
+ "the work, then run:\n"
309
+ ' asrt checkpoint --stack <STACK_ID> "<progress message>"'
310
+ )
311
+ ctx, metadata = start_new_session(start_path=Path.cwd(), stack_id=stack)
285
312
 
286
313
  stack_id = metadata.stack_id
287
314
  assert stack_id is not None
288
315
 
289
316
  append_checkpoint_entry(ctx.checkpoint_path, message)
290
- # `head_sha` here is the diff base the latest commit on origin that's
291
- # also reachable from local HEAD. Backend uses it as the checkout target
292
- # before applying the diff; sending the actual local HEAD would break
293
- # when the agent has uncommitted-to-origin commits (e.g. Codex Cloud).
294
- base_sha = get_diff_base_sha(ctx.repo_root)
317
+ # Base SHA is whatever `asrt init` recorded for this repo. Backend uses
318
+ # it as the checkout target before applying the diff. Pinning it at init
319
+ # time (instead of rediscovering per command) means sandboxes without
320
+ # remote-tracking refs e.g. Codex Cloud — still work.
321
+ base_sha = read_init_base_sha(ctx.assertion_dir)
295
322
  head_branch = get_head_branch(ctx.repo_root)
296
323
  diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
297
- metadata = update_metadata_head(
324
+ metadata = update_metadata_anchor(
298
325
  ctx.metadata_path, metadata, base_sha, head_branch=head_branch
299
326
  )
300
327
 
@@ -342,17 +369,34 @@ def _load_verify_session_id(assertion_dir: Path) -> str:
342
369
  return content
343
370
 
344
371
 
372
+ def _decode_screenshot(uri: str) -> tuple[bytes, str]:
373
+ """Resolve a screenshot reference to (image bytes, file extension).
374
+
375
+ The backend may hand back either an inline ``data:`` URI (legacy format)
376
+ or an http(s) download URL pointing at the files proxy (current format,
377
+ e.g. ``.../api/v0/files/download/<token>``). Support both so a format
378
+ change on the backend doesn't break status reporting.
379
+ """
380
+ if uri.startswith("data:") and "," in uri:
381
+ header, b64data = uri.split(",", 1)
382
+ ext = "jpeg" if "jpeg" in header else "png"
383
+ return base64.b64decode(b64data), ext
384
+
385
+ response = httpx.get(uri, timeout=30.0, follow_redirects=True)
386
+ response.raise_for_status()
387
+ content_type = response.headers.get("content-type", "").lower()
388
+ is_jpeg = "jpeg" in content_type or uri.lower().endswith((".jpg", ".jpeg"))
389
+ return response.content, "jpeg" if is_jpeg else "png"
390
+
391
+
345
392
  def _write_screenshots(
346
393
  assertion_dir: Path, session_id: str, screenshots: list[str]
347
394
  ) -> Path:
348
395
  screenshot_dir = assertion_dir / "screenshots" / session_id
349
396
  screenshot_dir.mkdir(parents=True, exist_ok=True)
350
397
  for idx, uri in enumerate(screenshots):
351
- _, b64data = uri.split(",", 1)
352
- ext = "jpeg" if "jpeg" in uri.split(",")[0] else "png"
353
- (screenshot_dir / f"screenshot_{idx:03d}.{ext}").write_bytes(
354
- base64.b64decode(b64data)
355
- )
398
+ data, ext = _decode_screenshot(uri)
399
+ (screenshot_dir / f"screenshot_{idx:03d}.{ext}").write_bytes(data)
356
400
  return screenshot_dir
357
401
 
358
402
 
@@ -374,11 +418,12 @@ def verify(
374
418
  ctx, metadata = load_existing_session(Path.cwd())
375
419
  stack_id = metadata.stack_id
376
420
  assert stack_id is not None
377
- # See checkpoint() for why we send the diff base rather than local HEAD.
378
- base_sha = get_diff_base_sha(ctx.repo_root)
421
+ # See checkpoint() for why the base is the SHA `asrt init` recorded,
422
+ # not local HEAD.
423
+ base_sha = read_init_base_sha(ctx.assertion_dir)
379
424
  head_branch = get_head_branch(ctx.repo_root)
380
425
  diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
381
- metadata = update_metadata_head(
426
+ metadata = update_metadata_anchor(
382
427
  ctx.metadata_path, metadata, base_sha, head_branch=head_branch
383
428
  )
384
429
 
@@ -451,8 +496,20 @@ def verify_status(
451
496
 
452
497
  terminal = payload.status in {SessionStatus.SUCCEEDED, SessionStatus.FAILED}
453
498
  screenshot_count = len(payload.screenshots)
499
+ screenshots_saved = False
454
500
  if terminal and payload.screenshots:
455
- _write_screenshots(ctx.assertion_dir, session_id, payload.screenshots)
501
+ # Saving screenshots must never block the verdict. A backend format
502
+ # change or a transient download failure should degrade to a warning,
503
+ # not swallow a `succeeded`/`failed` status the caller is waiting on.
504
+ try:
505
+ _write_screenshots(ctx.assertion_dir, session_id, payload.screenshots)
506
+ screenshots_saved = True
507
+ except Exception as exc: # noqa: BLE001 - never let screenshots hide the verdict
508
+ typer.echo(
509
+ f"warning: could not save screenshots ({exc}); "
510
+ "the verdict below is unaffected.",
511
+ err=True,
512
+ )
456
513
 
457
514
  if json_output:
458
515
  typer.echo(
@@ -473,7 +530,7 @@ def verify_status(
473
530
  typer.echo(payload.message)
474
531
  if payload.summary:
475
532
  typer.echo(f"\nSummary:\n{payload.summary}")
476
- if terminal and payload.screenshots:
533
+ if screenshots_saved:
477
534
  typer.echo(
478
535
  f"\nScreenshots ({screenshot_count}) saved to "
479
536
  f".assertion/screenshots/{session_id}/"
models.py CHANGED
@@ -62,11 +62,22 @@ class ErrorResponse(BaseModel):
62
62
 
63
63
 
64
64
  class MetadataPayload(BaseModel):
65
+ """Local on-disk schema for `.assertion/metadata.json`.
66
+
67
+ `base_sha` is the diff base (the commit `asrt init` recorded; the commit
68
+ the backend checks out before applying the diff). In the bundle sent to
69
+ the backend it is remapped to `head_sha` for wire compatibility — from
70
+ the backend's perspective, after `git checkout <sha>`, that SHA IS its
71
+ HEAD, so the name reads correctly on the consumer side. Locally we keep
72
+ `base_sha` because it matches what the CLI stores (`.assertion/base_sha`)
73
+ and what the value represents from the CLI's perspective.
74
+ """
75
+
65
76
  model_config = ConfigDict(extra="allow")
66
77
 
67
78
  session_id: str
68
79
  stack_id: str | None = None
69
- head_sha: str | None = None
80
+ base_sha: str | None = None
70
81
  head_branch: str | None = None
71
82
 
72
83
 
session.py CHANGED
@@ -6,8 +6,6 @@ from api import AssertionClient
6
6
  from git import (
7
7
  exit_with_error,
8
8
  find_git_root,
9
- get_diff_base_sha,
10
- get_origin_github_repo,
11
9
  )
12
10
  from models import MetadataPayload, SessionContext, render_stack_list
13
11
 
@@ -15,13 +13,35 @@ ASSERTION_DIR_NAME = ".assertion"
15
13
  PROMPTS_FILE_NAME = "prompts"
16
14
  CHECKPOINT_FILE_NAME = "checkpoint"
17
15
  METADATA_FILE_NAME = "metadata.json"
16
+ BASE_SHA_FILE_NAME = "base_sha"
17
+
18
+
19
+ def read_init_base_sha(assertion_dir: Path) -> str:
20
+ """Return the diff base SHA recorded by `asrt init`.
21
+
22
+ Exits with a clear error if init hasn't been run or the file is empty.
23
+ The base SHA is the commit the backend `git checkout`s against before
24
+ applying the diff, so every checkpoint/verify must have it.
25
+ """
26
+ path = assertion_dir / BASE_SHA_FILE_NAME
27
+ if not path.exists():
28
+ exit_with_error(
29
+ "No diff base recorded for this repo. Run `asrt init` first — "
30
+ "it captures the HEAD SHA that checkpoint/verify diff against."
31
+ )
32
+ content = path.read_text(encoding="utf-8").strip()
33
+ if not content:
34
+ exit_with_error(
35
+ f"{path.name} is empty. Re-run `asrt init` to record the diff base."
36
+ )
37
+ return content
18
38
 
19
39
 
20
40
  def _require_repo_ready(start_path: Path) -> Path:
21
41
  repo_root = find_git_root(start_path)
22
- # Fail fast here if no remote-reachable base exists. Result is discarded
23
- # callers re-compute at diff time, which is cheap.
24
- get_diff_base_sha(repo_root)
42
+ # Fail fast if `asrt init` hasn't run checkpoint/verify need the base
43
+ # SHA it records before doing any work or making any network calls.
44
+ read_init_base_sha(repo_root / ASSERTION_DIR_NAME)
25
45
  return repo_root
26
46
 
27
47
 
@@ -101,50 +121,6 @@ def _stacks_hint() -> str:
101
121
  )
102
122
 
103
123
 
104
- def resolve_stack_id_for_repo(repo_root: Path) -> str:
105
- """Pick the stack whose `repo` field matches the current repo's origin.
106
-
107
- Exits with a clear error if zero or more than one stack matches. The
108
- enforcement is here (in the CLI) so the coding agent does not have to
109
- follow a compliance rule — the rule is mechanical.
110
- """
111
- owner_name = get_origin_github_repo(repo_root)
112
- if owner_name is None:
113
- exit_with_error(
114
- "Could not determine this repo's GitHub `owner/name` from the `origin` "
115
- "remote. Pass `--stack <id>` explicitly to override."
116
- )
117
-
118
- client = AssertionClient()
119
- stacks = client.stacks()
120
-
121
- if not stacks:
122
- exit_with_error(
123
- "No verification stacks exist in this workspace yet. Open the "
124
- "Assertion web app, create a stack, and attach this repo "
125
- f"(`{owner_name}`) to it. Then re-run the command."
126
- )
127
-
128
- matches = [s for s in stacks if s.repo.strip() == owner_name]
129
-
130
- if len(matches) == 1:
131
- return matches[0].id
132
- if len(matches) == 0:
133
- bound_repos = sorted({s.repo.strip() for s in stacks})
134
- exit_with_error(
135
- f"No verification stack is attached to this repo (`{owner_name}`)."
136
- f" Other stacks in this workspace are attached to: {', '.join(bound_repos)}."
137
- " Open the Assertion web app, choose a stack,"
138
- " and attach this repo to it. Then re-run the command."
139
- )
140
- # More than one match — let the agent / user pick explicitly.
141
- names = ", ".join(f"{s.name} ({s.id})" for s in matches)
142
- exit_with_error(
143
- f"Multiple stacks are attached to `{owner_name}`: {names}. "
144
- "Pass `--stack <id>` to disambiguate."
145
- )
146
-
147
-
148
124
  def start_new_session(
149
125
  *,
150
126
  start_path: Path,
@@ -209,14 +185,20 @@ def append_checkpoint_entry(checkpoint_path: Path, message: str) -> None:
209
185
  f.write(f"{normalized}\n\n")
210
186
 
211
187
 
212
- def update_metadata_head(
188
+ def update_metadata_anchor(
213
189
  metadata_path: Path,
214
190
  metadata: MetadataPayload,
215
- head_sha: str,
191
+ base_sha: str,
216
192
  head_branch: str | None = None,
217
193
  ) -> MetadataPayload:
194
+ """Refresh the per-session git anchor: diff base + branch name.
195
+
196
+ `base_sha` is the diff base (recorded by `asrt init`, same value on every
197
+ call within a session). `head_branch` is the current local branch — may
198
+ legitimately change within a session if the user renames or switches.
199
+ """
218
200
  updated = metadata.model_copy(
219
- update={"head_sha": head_sha, "head_branch": head_branch}
201
+ update={"base_sha": base_sha, "head_branch": head_branch}
220
202
  )
221
203
  metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
222
204
  return updated
@@ -1,15 +0,0 @@
1
- api.py,sha256=6RuyOoZrsBTY_51XA60iHwkYJ6nqME0CrmDgGaEbWzk,6901
2
- bundle.py,sha256=e1-a0_hcKG0Xitjq0ac5u7ox3soAiodVbKIf4pRmI4w,828
3
- git.py,sha256=IDiy5ZQPJwCFEecTabmcaHA_jEyM06Al3hnY1lKqyRM,7637
4
- link.py,sha256=bfH0MhkeTFGbOSJbSvuuw3ilGX3akyvjvCw25AP_fz8,643
5
- main.py,sha256=7SwKc8n7YkD6Jh5dNgs1ul5BSlClp-5RaDv2FMPDBis,18121
6
- models.py,sha256=PQ0q_kUUzRVme325TXB_8eQaNl4LPSuxY8CVy9HK2ls,1678
7
- session.py,sha256=or0SQPuqJILe2wkQT32wmqY2TTXt0l5medIF7oZovNs,7130
8
- assertion_cli_templates/ACTIVATION.md,sha256=iDCSr87McqKX2ui1cQtKhk2Rcct04q_BIKAYpytaULo,1423
9
- assertion_cli_templates/SKILL.md,sha256=YTHMN8PQtkVzZEmPRMighHZ4zakwv1kOKqM0wT01hYY,13059
10
- assertion_cli_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
- assertion_cli-0.2.0.dist-info/METADATA,sha256=asC8-oqwe21zEdE8iPQSfB5kyXSFLIc3EuRzukdrkjY,1712
12
- assertion_cli-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
13
- assertion_cli-0.2.0.dist-info/entry_points.txt,sha256=LgwuPLZEIk-4sD7ghGVRQOhm3PG8sPVozMRxrihwMgU,34
14
- assertion_cli-0.2.0.dist-info/top_level.txt,sha256=xbZYH9xQOa99z-Vbkc2Xp6h8oUR97DVlyczy8H4ZuFc,64
15
- assertion_cli-0.2.0.dist-info/RECORD,,