assertion-cli 0.5.16__tar.gz → 0.5.18__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 (37) hide show
  1. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/PKG-INFO +14 -9
  2. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/README.md +13 -8
  3. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/api.py +10 -6
  4. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/PKG-INFO +14 -9
  5. assertion_cli-0.5.18/credentials.py +253 -0
  6. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/main.py +45 -18
  7. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/pyproject.toml +1 -1
  8. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/SKILL.md +2 -2
  9. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_auth.py +5 -1
  10. assertion_cli-0.5.18/tests/test_credentials.py +189 -0
  11. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_verify.py +78 -76
  12. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_verify_integration.py +45 -0
  13. assertion_cli-0.5.16/credentials.py +0 -120
  14. assertion_cli-0.5.16/tests/test_credentials.py +0 -122
  15. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/SOURCES.txt +0 -0
  16. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/dependency_links.txt +0 -0
  17. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/entry_points.txt +0 -0
  18. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/requires.txt +0 -0
  19. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/assertion_cli.egg-info/top_level.txt +0 -0
  20. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/bundle.py +0 -0
  21. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/git.py +0 -0
  22. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/link.py +0 -0
  23. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/models.py +0 -0
  24. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/session.py +0 -0
  25. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/setup.cfg +0 -0
  26. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/AGENTS.md +0 -0
  27. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/templates/__init__.py +0 -0
  28. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_api.py +0 -0
  29. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_bundle.py +0 -0
  30. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_decision.py +0 -0
  31. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_git.py +0 -0
  32. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_install.py +0 -0
  33. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_link.py +0 -0
  34. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_main.py +0 -0
  35. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_prompt.py +0 -0
  36. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_session.py +0 -0
  37. {assertion_cli-0.5.16 → assertion_cli-0.5.18}/tests/test_session_start.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.16
3
+ Version: 0.5.18
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
54
54
  ## Authentication
55
55
 
56
56
  `asrt login` stores your API token in a global, user-level file —
57
- `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
60
+ {
61
+ "version": 2,
62
+ "tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
63
+ "repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
64
+ }
61
65
  ```
62
66
 
63
67
  Run it from inside the repo it should authenticate. It validates the token and
64
68
  confirms the repo's `origin` remote is connected to a stack in the token's
65
- workspace before saving, so each repo can target a different workspace. Because
66
- entries are keyed by path, moving or renaming the repo folder means logging in
67
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
68
- `asrt whoami` shows which token is in effect.
69
+ workspace before saving the token against that workspace's stack repos, so each
70
+ repo can target a different workspace while moved folders and fresh clones keep
71
+ working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
72
+ shows which token is in effect.
69
73
 
70
74
  Token resolution at call time, in order:
71
75
 
72
- 1. the entry for the current repo path in `~/.assertion/credentials.json`;
73
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
76
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
77
+ 2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
78
+ 3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
74
79
  with no remote that `asrt login` won't store);
75
80
 
76
81
  with a clear "run `asrt login`" error when neither is present. The API base URL
@@ -41,23 +41,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
41
41
  ## Authentication
42
42
 
43
43
  `asrt login` stores your API token in a global, user-level file —
44
- `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
44
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
45
45
 
46
46
  ```json
47
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
47
+ {
48
+ "version": 2,
49
+ "tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
50
+ "repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
51
+ }
48
52
  ```
49
53
 
50
54
  Run it from inside the repo it should authenticate. It validates the token and
51
55
  confirms the repo's `origin` remote is connected to a stack in the token's
52
- workspace before saving, so each repo can target a different workspace. Because
53
- entries are keyed by path, moving or renaming the repo folder means logging in
54
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
55
- `asrt whoami` shows which token is in effect.
56
+ workspace before saving the token against that workspace's stack repos, so each
57
+ repo can target a different workspace while moved folders and fresh clones keep
58
+ working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
59
+ shows which token is in effect.
56
60
 
57
61
  Token resolution at call time, in order:
58
62
 
59
- 1. the entry for the current repo path in `~/.assertion/credentials.json`;
60
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
63
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
64
+ 2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
65
+ 3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
61
66
  with no remote that `asrt login` won't store);
62
67
 
63
68
  with a clear "run `asrt login`" error when neither is present. The API base URL
@@ -9,7 +9,7 @@ import typer
9
9
  from pydantic import BaseModel, ValidationError
10
10
 
11
11
  from credentials import API_TOKEN_ENV, resolve_token
12
- from git import find_git_root_or_none
12
+ from git import current_repo_full_name, find_git_root_or_none
13
13
  from models import (
14
14
  CheckpointResponse,
15
15
  DecisionResponse,
@@ -35,11 +35,15 @@ def _base_url() -> str:
35
35
  def _resolve_token() -> str | None:
36
36
  """Token for the current repo, else the shell env-var backup, else None.
37
37
 
38
- The stored credential (keyed by the exact current git root) is primary; a
39
- real `export ASSERTION_API_TOKEN` only fills in when no entry matches — e.g.
40
- in CI, or a repo that was moved/renamed and never re-authenticated.
38
+ Stored credentials are primarily keyed by the current repo's GitHub
39
+ ``owner/repo`` identity, with the legacy exact git-root path as fallback. A
40
+ real `export ASSERTION_API_TOKEN` only fills in when no stored entry matches.
41
41
  """
42
- token = resolve_token(find_git_root_or_none(Path.cwd()))
42
+ repo_root = find_git_root_or_none(Path.cwd())
43
+ repo_full_name = (
44
+ current_repo_full_name(repo_root) if repo_root is not None else None
45
+ )
46
+ token = resolve_token(repo_root, repo_full_name)
43
47
  if token:
44
48
  return token
45
49
  env_token = (os.environ.get(API_TOKEN_ENV) or "").strip()
@@ -53,7 +57,7 @@ def _auth_headers() -> dict[str, str]:
53
57
  where = f" for {repo_root}" if repo_root else ""
54
58
  typer.echo(
55
59
  f"No saved Assertion token{where}. Run `asrt login` to authenticate "
56
- "this repo (if you moved or renamed the folder, log in again).\n"
60
+ "this repo.\n"
57
61
  f"Alternatively, export {API_TOKEN_ENV} in your shell as a backup.",
58
62
  err=True,
59
63
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.16
3
+ Version: 0.5.18
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -54,23 +54,28 @@ asrt verify-status # one-shot poll; loop with your own sleep
54
54
  ## Authentication
55
55
 
56
56
  `asrt login` stores your API token in a global, user-level file —
57
- `~/.assertion/credentials.json` (mode `0600`) — keyed by the repo's path:
57
+ `~/.assertion/credentials.json` (mode `0600`) — with an inverted repo index:
58
58
 
59
59
  ```json
60
- { "version": 1, "repos": { "/abs/path/to/repo": "alk_..." } }
60
+ {
61
+ "version": 2,
62
+ "tokens": { "alk_...": ["acme/mvp", "acme/docs"] },
63
+ "repo_index": { "acme/mvp": "alk_...", "acme/docs": "alk_..." }
64
+ }
61
65
  ```
62
66
 
63
67
  Run it from inside the repo it should authenticate. It validates the token and
64
68
  confirms the repo's `origin` remote is connected to a stack in the token's
65
- workspace before saving, so each repo can target a different workspace. Because
66
- entries are keyed by path, moving or renaming the repo folder means logging in
67
- again (which adds a new entry). `asrt logout` removes the current repo's entry;
68
- `asrt whoami` shows which token is in effect.
69
+ workspace before saving the token against that workspace's stack repos, so each
70
+ repo can target a different workspace while moved folders and fresh clones keep
71
+ working. `asrt logout` removes the current repo's saved auth; `asrt whoami`
72
+ shows which token is in effect.
69
73
 
70
74
  Token resolution at call time, in order:
71
75
 
72
- 1. the entry for the current repo path in `~/.assertion/credentials.json`;
73
- 2. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
76
+ 1. the token in `repo_index` for the current repo's GitHub `owner/repo`;
77
+ 2. a legacy entry for the current repo path in `~/.assertion/credentials.json`;
78
+ 3. the `ASSERTION_API_TOKEN` environment variable (a backup for CI, or repos
74
79
  with no remote that `asrt login` won't store);
75
80
 
76
81
  with a clear "run `asrt login`" error when neither is present. The API base URL
@@ -0,0 +1,253 @@
1
+ """Global, user-level token store at ``~/.assertion/credentials.json``.
2
+
3
+ The current store keeps plaintext tokens plus an inverted GitHub repo index, so
4
+ reclones or moved folders keep resolving as long as their ``origin`` still
5
+ points at a connected repo:
6
+
7
+ {
8
+ "version": 2,
9
+ "tokens": {"alk_...": ["acme/mvp"]},
10
+ "repo_index": {"acme/mvp": "alk_..."}
11
+ }
12
+
13
+ The legacy v1 ``repos`` path map is still read as a fallback:
14
+
15
+ {"version": 1, "repos": {"/abs/path/to/repo": "alk_..."}}
16
+
17
+ This module is deliberately git-free and pure stdlib — callers pass in the repo
18
+ root / repo full name (resolved via ``git.py``) and handle their own error
19
+ reporting. Token resolution precedence (repo entry first, then the env-var
20
+ backup) lives in ``api.py``; this module only owns on-disk reads and writes.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ from pathlib import Path
28
+
29
+ API_TOKEN_ENV = "ASSERTION_API_TOKEN"
30
+
31
+ CREDENTIALS_FILE_NAME = "credentials.json"
32
+ CREDENTIALS_VERSION = 2
33
+
34
+
35
+ def assertion_home() -> Path:
36
+ """Directory holding global Assertion credentials (``~/.assertion``)."""
37
+ return Path.home() / ".assertion"
38
+
39
+
40
+ def credentials_path() -> Path:
41
+ return assertion_home() / CREDENTIALS_FILE_NAME
42
+
43
+
44
+ def _empty_store() -> dict:
45
+ return {
46
+ "version": CREDENTIALS_VERSION,
47
+ "tokens": {},
48
+ "repo_index": {},
49
+ "repos": {},
50
+ }
51
+
52
+
53
+ def load_store() -> dict:
54
+ """Return the credentials store, tolerating a missing or corrupt file.
55
+
56
+ A malformed file is treated as empty rather than crashing every command —
57
+ the user can re-run ``asrt login`` to repair it.
58
+ """
59
+ path = credentials_path()
60
+ if not path.exists():
61
+ return _empty_store()
62
+ try:
63
+ data = json.loads(path.read_text(encoding="utf-8"))
64
+ except (json.JSONDecodeError, OSError, ValueError):
65
+ return _empty_store()
66
+ if not isinstance(data, dict):
67
+ return _empty_store()
68
+ data.setdefault("version", CREDENTIALS_VERSION)
69
+ if not isinstance(data.get("tokens"), dict):
70
+ data["tokens"] = {}
71
+ if not isinstance(data.get("repo_index"), dict):
72
+ data["repo_index"] = _repo_index_from_tokens(data["tokens"])
73
+ if not isinstance(data.get("repos"), dict):
74
+ data["repos"] = {}
75
+ return data
76
+
77
+
78
+ def _repo_key(repo_root: Path) -> str:
79
+ """Canonical store key for a repo root (absolute, symlinks resolved)."""
80
+ return str(repo_root.resolve())
81
+
82
+
83
+ def _repo_name_key(repo_full_name: str) -> str:
84
+ return repo_full_name.strip().lower()
85
+
86
+
87
+ def _normalize_repo_names(repo_full_names: list[str]) -> list[str]:
88
+ seen: set[str] = set()
89
+ normalized: list[str] = []
90
+ for repo in repo_full_names:
91
+ key = _repo_name_key(repo)
92
+ if not key or key in seen:
93
+ continue
94
+ seen.add(key)
95
+ normalized.append(key)
96
+ return normalized
97
+
98
+
99
+ def _repo_index_from_tokens(tokens: dict) -> dict[str, str]:
100
+ index: dict[str, str] = {}
101
+ for token, repos in tokens.items():
102
+ if not isinstance(token, str) or not isinstance(repos, list):
103
+ continue
104
+ for repo in repos:
105
+ key = _repo_name_key(str(repo))
106
+ if key:
107
+ index[key] = token
108
+ return index
109
+
110
+
111
+ def _write_store(store: dict) -> Path:
112
+ home = assertion_home()
113
+ home.mkdir(parents=True, exist_ok=True)
114
+ try:
115
+ home.chmod(0o700)
116
+ except OSError:
117
+ pass
118
+ path = credentials_path()
119
+ path.write_text(json.dumps(store, indent=2) + "\n", encoding="utf-8")
120
+ try:
121
+ path.chmod(0o600)
122
+ except OSError:
123
+ pass
124
+ return path
125
+
126
+
127
+ def save_token(
128
+ token: str, *, repo_root: Path, repo_full_names: list[str] | None = None
129
+ ) -> Path:
130
+ """Persist ``token`` and return the credentials file path.
131
+
132
+ ``repo_full_names`` are canonical GitHub ``owner/repo`` names visible to the
133
+ token. The legacy path entry is still written so older CLIs can read the
134
+ file during a rolling upgrade.
135
+ """
136
+ store = load_store()
137
+ if repo_full_names is not None:
138
+ normalized = _normalize_repo_names(repo_full_names)
139
+ normalized_set = set(normalized)
140
+ repo_index = store["repo_index"]
141
+ for repo, indexed_token in list(repo_index.items()):
142
+ if indexed_token == token or repo in normalized_set:
143
+ del repo_index[repo]
144
+ for saved_token, repos in list(store["tokens"].items()):
145
+ if saved_token == token or not isinstance(repos, list):
146
+ continue
147
+ filtered = [
148
+ _repo_name_key(str(repo))
149
+ for repo in repos
150
+ if _repo_name_key(str(repo)) not in normalized_set
151
+ ]
152
+ if filtered:
153
+ store["tokens"][saved_token] = filtered
154
+ else:
155
+ del store["tokens"][saved_token]
156
+ for repo in normalized:
157
+ if repo_index.get(repo) == saved_token:
158
+ del repo_index[repo]
159
+ store["tokens"][token] = normalized
160
+ for repo in normalized:
161
+ repo_index[repo] = token
162
+ store["repos"][_repo_key(repo_root)] = token
163
+ return _write_store(store)
164
+
165
+
166
+ def remove_token(*, repo_root: Path, repo_full_name: str | None = None) -> bool:
167
+ """Delete this repo's saved credential. Return ``True`` if one was removed."""
168
+ store = load_store()
169
+ key = _repo_key(repo_root)
170
+ token = store["repos"].pop(key, None)
171
+ removed = token is not None
172
+ if repo_full_name is not None:
173
+ repo_key = _repo_name_key(repo_full_name)
174
+ indexed_token = store["repo_index"].pop(repo_key, None)
175
+ target_token = token or indexed_token
176
+ for saved_token, repos in list(store["tokens"].items()):
177
+ if target_token is not None and saved_token != target_token:
178
+ continue
179
+ if not isinstance(repos, list):
180
+ continue
181
+ filtered = [
182
+ str(repo).strip().lower()
183
+ for repo in repos
184
+ if _repo_name_key(str(repo)) != repo_key
185
+ ]
186
+ if len(filtered) != len(repos):
187
+ removed = True
188
+ if filtered:
189
+ store["tokens"][saved_token] = filtered
190
+ else:
191
+ del store["tokens"][saved_token]
192
+ if saved_token == target_token:
193
+ break
194
+ removed = removed or indexed_token is not None
195
+ if token:
196
+ repo_refs = 0
197
+ for saved in store["repos"].values():
198
+ if saved == token:
199
+ repo_refs += 1
200
+ indexed_refs = [
201
+ indexed_token
202
+ for indexed_token in store["repo_index"].values()
203
+ if indexed_token == token
204
+ ]
205
+ if (
206
+ repo_refs == 0
207
+ and not indexed_refs
208
+ and token in store["tokens"]
209
+ and repo_full_name is None
210
+ ):
211
+ del store["tokens"][token]
212
+ if removed:
213
+ _write_store(store)
214
+ return removed
215
+
216
+
217
+ def resolve_token(
218
+ repo_root: Path | None, repo_full_name: str | None = None
219
+ ) -> str | None:
220
+ """Return the stored token for ``repo_full_name`` / ``repo_root``.
221
+
222
+ Repo-name matching is primary and survives reclones/moved folders. The
223
+ legacy exact path match is retained so existing credentials keep working.
224
+ """
225
+ store = load_store()
226
+ if repo_full_name is not None:
227
+ local = _repo_name_key(repo_full_name)
228
+ token = store["repo_index"].get(local)
229
+ if token:
230
+ return token
231
+ if repo_root is None:
232
+ return None
233
+ token = store["repos"].get(_repo_key(repo_root))
234
+ return token or None
235
+
236
+
237
+ def describe_source(
238
+ repo_root: Path | None, repo_full_name: str | None = None
239
+ ) -> str | None:
240
+ """Describe where the active token comes from, in resolution order.
241
+
242
+ Returns a human phrase for ``whoami``, or ``None`` when nothing resolves.
243
+ """
244
+ if (
245
+ repo_full_name is not None
246
+ and resolve_token(repo_root, repo_full_name) is not None
247
+ ):
248
+ return f"the saved token for {repo_full_name.strip().lower()}"
249
+ if repo_root is not None and resolve_token(repo_root) is not None:
250
+ return f"the saved token for {_repo_key(repo_root)}"
251
+ if (os.environ.get(API_TOKEN_ENV) or "").strip():
252
+ return f"the {API_TOKEN_ENV} environment variable"
253
+ return None
@@ -125,8 +125,9 @@ def login(
125
125
  """Authenticate this repo and save its token under ~/.assertion/.
126
126
 
127
127
  Generate a token at Settings → Authentication in the dashboard, then run
128
- this from inside the repo it should be used for. The token is stored keyed
129
- by the repo's path, so each repo can target a different workspace.
128
+ this from inside any repo it can access. The token is stored with the
129
+ workspace's stack repos, so moved folders and fresh clones keep working when
130
+ their `origin` remote matches.
130
131
 
131
132
  Only repos whose `origin` remote matches a stack in the token's workspace
132
133
  are stored; a repo with no remote can't be verified, so use the
@@ -173,7 +174,9 @@ def login(
173
174
  "the dashboard, then re-run `asrt login`."
174
175
  )
175
176
 
176
- path = save_token(token, repo_root=repo_root)
177
+ path = save_token(
178
+ token, repo_root=repo_root, repo_full_names=[s.repo for s in stack_list]
179
+ )
177
180
  names = ", ".join(f"'{s.name}'" for s in matched)
178
181
  typer.echo(f"✓ {local} is connected to stack(s): {names}")
179
182
  typer.echo(f"Saved token for {repo_root} to {path}")
@@ -186,7 +189,8 @@ def logout() -> None:
186
189
  if repo_root is None:
187
190
  exit_with_error("Not inside a git repository.")
188
191
 
189
- if remove_token(repo_root=repo_root):
192
+ local = current_repo_full_name(repo_root)
193
+ if remove_token(repo_root=repo_root, repo_full_name=local):
190
194
  typer.echo(f"Removed saved token for {repo_root}.")
191
195
  else:
192
196
  typer.echo(f"No saved token for {repo_root}.")
@@ -196,7 +200,8 @@ def logout() -> None:
196
200
  def whoami() -> None:
197
201
  """Show which token would be used here and confirm it works."""
198
202
  repo_root = find_git_root_or_none(Path.cwd())
199
- source = describe_source(repo_root)
203
+ local = current_repo_full_name(repo_root) if repo_root is not None else None
204
+ source = describe_source(repo_root, local)
200
205
  if source is None:
201
206
  exit_with_error(
202
207
  "Not authenticated. Run `asrt login` to save a token for this repo."
@@ -694,22 +699,36 @@ def _build_verify_bundle(
694
699
  return bundle_bytes, review_diff_text, compute_review_diff_hash(review_diff_text)
695
700
 
696
701
 
697
- def _try_reuse_successful_verify(
702
+ def _compute_review_diff_fingerprint(ctx) -> tuple[str, str]:
703
+ """Return (review_diff_text, review_diff_hash) without bundle side effects."""
704
+ verify_base = read_init_verify_base_sha(ctx.assertion_dir)
705
+ review_diff_text = get_full_diff(ctx.repo_root, verify_base)
706
+ return review_diff_text, compute_review_diff_hash(review_diff_text)
707
+
708
+
709
+ def _try_reuse_verify(
698
710
  assertion_dir: Path,
699
711
  *,
700
712
  review_diff_hash: str,
701
713
  json_output: bool,
702
714
  ) -> bool:
703
- """Reuse the prior verification when the review diff is unchanged.
715
+ """Reuse a prior verification when the review diff is unchanged.
704
716
 
705
- Returns True when a prior succeeded verification is reused (no new submission).
717
+ Returns True when an existing session is reused (no new submission). Covers
718
+ both terminal ``succeeded`` runs and in-flight ``created``/``running`` runs
719
+ so a repeated ``asrt verify`` does not submit twice for the same diff.
706
720
  """
707
721
  stored_hash = read_review_diff_hash(assertion_dir)
708
722
  if stored_hash != review_diff_hash:
709
723
  return False
710
724
 
711
725
  last_status = read_verify_last_status(assertion_dir)
712
- if last_status != SessionStatus.SUCCEEDED.value:
726
+ reusable_statuses = {
727
+ SessionStatus.SUCCEEDED.value,
728
+ SessionStatus.CREATED.value,
729
+ SessionStatus.RUNNING.value,
730
+ }
731
+ if last_status not in reusable_statuses:
713
732
  return False
714
733
 
715
734
  session_id = _read_optional_verify_session_id(assertion_dir)
@@ -726,12 +745,12 @@ def _try_reuse_successful_verify(
726
745
  {
727
746
  "session_id": session_id,
728
747
  "url": session_url,
729
- "status": SessionStatus.SUCCEEDED.value,
748
+ "status": last_status,
730
749
  "reused": True,
731
750
  }
732
751
  )
733
752
  )
734
- else:
753
+ elif last_status == SessionStatus.SUCCEEDED.value:
735
754
  typer.echo(
736
755
  "Review diff unchanged since the last successful verification — "
737
756
  f"reusing session {session_id}."
@@ -740,6 +759,13 @@ def _try_reuse_successful_verify(
740
759
  typer.echo(
741
760
  "Embed `asrt get-link` in the PR body if the branch name may differ."
742
761
  )
762
+ else:
763
+ typer.echo(
764
+ "Review diff unchanged since the last verification submission — "
765
+ f"session {session_id} is still {last_status}."
766
+ )
767
+ typer.echo(f"URL: {session_url}")
768
+ typer.echo("Poll `asrt verify-status` instead of submitting again.")
743
769
  return True
744
770
 
745
771
 
@@ -763,24 +789,25 @@ def verify(
763
789
  this command only submits — poll `asrt verify-status` (one-shot) on whatever
764
790
  interval your harness prefers to learn the outcome.
765
791
 
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.
792
+ When the review-scope diff is unchanged since the last verification
793
+ submission, the CLI reuses that session instead of submitting again
794
+ including while a prior run is still ``running``. Pass ``--force`` to always
795
+ submit.
769
796
  """
770
797
  ctx, metadata = load_existing_session(Path.cwd())
771
798
  stack_id = metadata.stack_id
772
799
  assert stack_id is not None
773
- bundle_bytes, review_diff_text, review_diff_hash = _build_verify_bundle(
774
- ctx, metadata
775
- )
800
+ _, review_diff_hash = _compute_review_diff_fingerprint(ctx)
776
801
 
777
- if not force and _try_reuse_successful_verify(
802
+ if not force and _try_reuse_verify(
778
803
  ctx.assertion_dir,
779
804
  review_diff_hash=review_diff_hash,
780
805
  json_output=json_output,
781
806
  ):
782
807
  return
783
808
 
809
+ bundle_bytes, _, review_diff_hash = _build_verify_bundle(ctx, metadata)
810
+
784
811
  client = AssertionClient()
785
812
  resp = client.verify(
786
813
  stack_id=stack_id,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.16"
7
+ version = "0.5.18"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -21,7 +21,7 @@ Before any `asrt` call:
21
21
 
22
22
  - You are inside a git repository.
23
23
  - `asrt install` has been run once in this repo. It installs the skill files you are reading right now (plus the `AGENTS.md` / `CLAUDE.md` pointer and `.gitignore` rules). This is an onboarding step, not a per-run one.
24
- - `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by repo path) — not in a repo `.env`. If an `asrt` call fails with "No saved Assertion token … run `asrt login`", the user needs to run `asrt login` (or export `ASSERTION_API_TOKEN`); surface that to them rather than retrying.
24
+ - `asrt login` has been run once to authenticate this repo. It stores the API token in `~/.assertion/credentials.json` (keyed by GitHub `owner/repo`) — not in a repo `.env`. If an `asrt` call fails with "No saved Assertion token … run `asrt login`", the user needs to run `asrt login` (or export `ASSERTION_API_TOKEN`); surface that to them rather than retrying.
25
25
  - `asrt session start` has been run for this work session. It resets `.assertion/` (except `.assertion/seed.py`) and records the verification base — the merge-base with the upstream tracking branch by default, or an explicit `--from <ref>`. If `asrt checkpoint` or `asrt verify` errors with "No verification base recorded", run `asrt session start` and retry.
26
26
 
27
27
  **First command sequence for a new work session:**
@@ -144,7 +144,7 @@ Do not call `verify` mid-task. It is not a substitute for checkpoints; it is the
144
144
 
145
145
  `verify` is **non-blocking**: it submits the verification and returns immediately with the session id and URL. The verification itself can take several minutes — you poll `asrt verify-status` on your own cadence to learn the outcome. This avoids tripping any shell-timeout in the harness.
146
146
 
147
- When the review diff is unchanged since the last **succeeded** verification, `asrt verify` reuses that session instead of submitting again. Use `asrt verify --force` to submit anyway. After a new git branch, run `asrt session start` only when the verification base should change — check the scope line it prints, or pass `--from <ref>` if the default is wrong.
147
+ When the review diff is unchanged since the last verification submission, `asrt verify` reuses that session instead of submitting again — including while a prior run is still `running` (poll `asrt verify-status` instead). After a **succeeded** run, reuse skips a redundant submission. 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
148
 
149
149
  ```
150
150
  asrt verify # submit; prints session id + URL, exits 0
@@ -43,7 +43,9 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
43
43
 
44
44
  @respx.mock
45
45
  def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
46
- route = respx.get(STACKS_URL).mock(return_value=_stacks_response("acme/mvp"))
46
+ route = respx.get(STACKS_URL).mock(
47
+ return_value=_stacks_response("acme/mvp", "acme/docs")
48
+ )
47
49
 
48
50
  result = runner.invoke(main.app, ["login", "--token", "alk_new"])
49
51
 
@@ -51,6 +53,8 @@ def test_login_saves_when_repo_matches_stack(repo: Path) -> None:
51
53
  assert "connected to stack" in result.output.lower()
52
54
  assert route.called
53
55
  assert credentials.resolve_token(repo) == "alk_new"
56
+ assert credentials.resolve_token(None, "acme/mvp") == "alk_new"
57
+ assert credentials.resolve_token(None, "acme/docs") == "alk_new"
54
58
 
55
59
 
56
60
  @respx.mock