assertion-cli 0.2.0__tar.gz → 0.4.0__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 (32) hide show
  1. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/PKG-INFO +1 -1
  2. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/PKG-INFO +1 -1
  3. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/SOURCES.txt +1 -2
  4. assertion_cli-0.4.0/bundle.py +42 -0
  5. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/git.py +0 -101
  6. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/main.py +94 -37
  7. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/models.py +12 -1
  8. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/pyproject.toml +1 -1
  9. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/session.py +34 -52
  10. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/ACTIVATION.md +1 -1
  11. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/SKILL.md +37 -46
  12. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_bundle.py +5 -1
  13. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_git.py +9 -88
  14. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_init.py +56 -0
  15. assertion_cli-0.4.0/tests/test_main.py +82 -0
  16. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_session.py +64 -8
  17. assertion_cli-0.2.0/bundle.py +0 -26
  18. assertion_cli-0.2.0/tests/test_main.py +0 -23
  19. assertion_cli-0.2.0/tests/test_stack_resolve.py +0 -120
  20. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/README.md +0 -0
  21. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/api.py +0 -0
  22. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/dependency_links.txt +0 -0
  23. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/entry_points.txt +0 -0
  24. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/requires.txt +0 -0
  25. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/assertion_cli.egg-info/top_level.txt +0 -0
  26. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/link.py +0 -0
  27. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/setup.cfg +0 -0
  28. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/templates/__init__.py +0 -0
  29. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_api.py +0 -0
  30. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_decision.py +0 -0
  31. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_link.py +0 -0
  32. {assertion_cli-0.2.0 → assertion_cli-0.4.0}/tests/test_prompt.py +0 -0
@@ -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
@@ -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
@@ -24,5 +24,4 @@ tests/test_init.py
24
24
  tests/test_link.py
25
25
  tests/test_main.py
26
26
  tests/test_prompt.py
27
- tests/test_session.py
28
- tests/test_stack_resolve.py
27
+ tests/test_session.py
@@ -0,0 +1,42 @@
1
+ import io
2
+ import json
3
+ import zipfile
4
+
5
+ from models import MetadataPayload
6
+
7
+ ASSERTION_DIR_NAME = ".assertion"
8
+ DIFF_ARCHIVE_PATH = "git.diff"
9
+ METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
10
+ PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
11
+ CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
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
+
29
+ def build_bundle(
30
+ *,
31
+ metadata: MetadataPayload,
32
+ diff_text: str,
33
+ prompts_text: str,
34
+ checkpoint_text: str,
35
+ ) -> bytes:
36
+ buf = io.BytesIO()
37
+ with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
38
+ zf.writestr(METADATA_ARCHIVE_PATH, _metadata_wire_json(metadata))
39
+ zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
40
+ zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
41
+ zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
42
+ return buf.getvalue()
@@ -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
  [
@@ -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}/"
@@ -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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.2.0"
7
+ version = "0.4.0"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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
@@ -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`.