assertion-cli 0.5.7__tar.gz → 0.5.9__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 (34) hide show
  1. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/PKG-INFO +3 -1
  2. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/README.md +2 -0
  3. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/PKG-INFO +3 -1
  4. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/SOURCES.txt +4 -2
  5. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/bundle.py +3 -0
  6. assertion_cli-0.5.9/git.py +199 -0
  7. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/main.py +127 -39
  8. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/models.py +1 -1
  9. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/pyproject.toml +1 -1
  10. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/session.py +13 -11
  11. assertion_cli-0.5.9/templates/AGENTS.md +14 -0
  12. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/templates/SKILL.md +3 -2
  13. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_bundle.py +8 -0
  14. assertion_cli-0.5.9/tests/test_git.py +478 -0
  15. assertion_cli-0.5.9/tests/test_install.py +155 -0
  16. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_new.py +29 -11
  17. assertion_cli-0.5.9/tests/test_session_start.py +251 -0
  18. assertion_cli-0.5.7/git.py +0 -126
  19. assertion_cli-0.5.7/tests/test_git.py +0 -257
  20. assertion_cli-0.5.7/tests/test_init.py +0 -320
  21. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/api.py +0 -0
  22. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/dependency_links.txt +0 -0
  23. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/entry_points.txt +0 -0
  24. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/requires.txt +0 -0
  25. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/top_level.txt +0 -0
  26. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/link.py +0 -0
  27. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/setup.cfg +0 -0
  28. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/templates/__init__.py +0 -0
  29. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_api.py +0 -0
  30. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_decision.py +0 -0
  31. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_link.py +0 -0
  32. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_main.py +0 -0
  33. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_prompt.py +0 -0
  34. {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -39,6 +39,8 @@ that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
39
39
  After installation:
40
40
 
41
41
  ```bash
42
+ asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
43
+ asrt session start # per work session: record the diff base (current HEAD)
42
44
  asrt new # reset local run state for a fresh session
43
45
  asrt stacks
44
46
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
@@ -25,6 +25,8 @@ that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
25
25
  After installation:
26
26
 
27
27
  ```bash
28
+ asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
29
+ asrt session start # per work session: record the diff base (current HEAD)
28
30
  asrt new # reset local run state for a fresh session
29
31
  asrt stacks
30
32
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: assertion-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
4
4
  Summary: CLI for the Assertion API
5
5
  Requires-Python: >=3.13
6
6
  Description-Content-Type: text/markdown
@@ -39,6 +39,8 @@ that set is `httpx`, `pydantic`, `python-dotenv`, and `typer`.
39
39
  After installation:
40
40
 
41
41
  ```bash
42
+ asrt install # one-time onboarding: write skill files, AGENTS.md/CLAUDE.md pointer, .gitignore
43
+ asrt session start # per work session: record the diff base (current HEAD)
42
44
  asrt new # reset local run state for a fresh session
43
45
  asrt stacks
44
46
  asrt checkpoint --stack <stack-id> "Implemented X\nUpdated Y"
@@ -13,15 +13,17 @@ assertion_cli.egg-info/dependency_links.txt
13
13
  assertion_cli.egg-info/entry_points.txt
14
14
  assertion_cli.egg-info/requires.txt
15
15
  assertion_cli.egg-info/top_level.txt
16
+ templates/AGENTS.md
16
17
  templates/SKILL.md
17
18
  templates/__init__.py
18
19
  tests/test_api.py
19
20
  tests/test_bundle.py
20
21
  tests/test_decision.py
21
22
  tests/test_git.py
22
- tests/test_init.py
23
+ tests/test_install.py
23
24
  tests/test_link.py
24
25
  tests/test_main.py
25
26
  tests/test_new.py
26
27
  tests/test_prompt.py
27
- tests/test_session.py
28
+ tests/test_session.py
29
+ tests/test_session_start.py
@@ -6,6 +6,7 @@ from models import MetadataPayload
6
6
 
7
7
  ASSERTION_DIR_NAME = ".assertion"
8
8
  DIFF_ARCHIVE_PATH = "git.diff"
9
+ UNCOMMITTED_DIFF_ARCHIVE_PATH = "uncommitted.diff"
9
10
  METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
10
11
  PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
11
12
  CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
@@ -30,6 +31,7 @@ def build_bundle(
30
31
  *,
31
32
  metadata: MetadataPayload,
32
33
  diff_text: str,
34
+ uncommitted_diff_text: str,
33
35
  prompts_text: str,
34
36
  checkpoint_text: str,
35
37
  ) -> bytes:
@@ -39,4 +41,5 @@ def build_bundle(
39
41
  zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
40
42
  zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
41
43
  zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
44
+ zf.writestr(UNCOMMITTED_DIFF_ARCHIVE_PATH, uncommitted_diff_text)
42
45
  return buf.getvalue()
@@ -0,0 +1,199 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+ from typing import NoReturn, Sequence
4
+
5
+ import typer
6
+
7
+
8
+ def exit_with_error(message: str) -> NoReturn:
9
+ typer.echo(message, err=True)
10
+ raise typer.Exit(code=1)
11
+
12
+
13
+ def run_git_command(
14
+ repo_root: Path, args: Sequence[str], *, preserve_stdout: bool = False
15
+ ) -> str:
16
+ completed = subprocess.run(
17
+ ["git", *args],
18
+ cwd=repo_root,
19
+ capture_output=True,
20
+ text=True,
21
+ check=False,
22
+ )
23
+ if completed.returncode != 0:
24
+ message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
25
+ raise RuntimeError(message)
26
+ return completed.stdout if preserve_stdout else completed.stdout.strip()
27
+
28
+
29
+ def find_git_root(start_path: Path) -> Path:
30
+ completed = subprocess.run(
31
+ ["git", "rev-parse", "--show-toplevel"],
32
+ cwd=start_path,
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ if completed.returncode != 0:
38
+ exit_with_error("Current directory is not inside a git repository.")
39
+ return Path(completed.stdout.strip())
40
+
41
+
42
+ def get_head_sha(repo_root: Path) -> str:
43
+ try:
44
+ return run_git_command(repo_root, ["rev-parse", "HEAD"])
45
+ except RuntimeError as exc:
46
+ exit_with_error(f"Failed to get HEAD SHA: {exc}")
47
+
48
+
49
+ def get_upstream_ref(repo_root: Path) -> str | None:
50
+ """Return the upstream tracking ref (e.g. ``origin/main``).
51
+
52
+ Tries HEAD's configured upstream first (``HEAD@{upstream}``). When the
53
+ branch has no upstream but the branch name itself resolves on the default
54
+ remote, falls back to ``origin/<branch>``. Returns ``None`` when neither
55
+ is available (detached HEAD, no remote, branch not pushed).
56
+ """
57
+ try:
58
+ return run_git_command(
59
+ repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]
60
+ )
61
+ except RuntimeError:
62
+ pass
63
+
64
+ head_branch = get_head_branch(repo_root)
65
+ if head_branch is not None:
66
+ fallback = f"origin/{head_branch}"
67
+ try:
68
+ run_git_command(repo_root, ["rev-parse", "--verify", fallback])
69
+ except RuntimeError:
70
+ pass
71
+ else:
72
+ return fallback
73
+
74
+ return None
75
+
76
+
77
+ def get_merge_base(repo_root: Path, upstream: str) -> str:
78
+ """Return the merge-base of ``HEAD`` and *upstream*."""
79
+ try:
80
+ return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
81
+ except RuntimeError as exc:
82
+ exit_with_error(
83
+ f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
84
+ )
85
+
86
+
87
+ def get_head_branch(repo_root: Path) -> str | None:
88
+ """Return the current branch name, or None if HEAD is detached."""
89
+ try:
90
+ name = run_git_command(repo_root, ["rev-parse", "--abbrev-ref", "HEAD"])
91
+ except RuntimeError:
92
+ return None
93
+ return name if name and name != "HEAD" else None
94
+
95
+
96
+ def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
97
+ completed = subprocess.run(
98
+ [
99
+ "git",
100
+ "diff",
101
+ "--no-index",
102
+ "--src-prefix=a/",
103
+ "--dst-prefix=b/",
104
+ "--",
105
+ "/dev/null",
106
+ rel_path,
107
+ ],
108
+ cwd=repo_root,
109
+ capture_output=True,
110
+ text=True,
111
+ check=False,
112
+ )
113
+ if completed.returncode not in (0, 1):
114
+ message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
115
+ raise RuntimeError(message)
116
+ return completed.stdout
117
+
118
+
119
+ # Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
120
+ # reviewers don't flag our own bootstrap files as "unrelated changes" — they're
121
+ # generated by `asrt install` and have nothing to do with the user's feature
122
+ # work. CLAUDE.md / AGENTS.md are intentionally NOT in this list: `asrt install`
123
+ # writes a small pointer block into them once at onboarding (meant to be
124
+ # committed before work starts, so it never shows in a work-session diff), but
125
+ # the rest of those files is the user's own and must flow through to reviewers.
126
+ _ASSERTION_EXCLUDED_PATHSPECS = [
127
+ ":(exclude).assertion",
128
+ ":(exclude).claude/skills/assertion-cli",
129
+ ":(exclude).agents/skills/assertion-cli",
130
+ ]
131
+
132
+
133
+ def _build_tracked_diff(repo_root: Path, base: str) -> str:
134
+ """``git diff <base>`` for tracked files, excluding assertion-owned paths."""
135
+ return run_git_command(
136
+ repo_root,
137
+ ["diff", base, "--", *_ASSERTION_EXCLUDED_PATHSPECS],
138
+ preserve_stdout=True,
139
+ )
140
+
141
+
142
+ def _build_untracked_diffs(repo_root: Path) -> list[str]:
143
+ """Diff of every untracked (but non-ignored) file as a new-file diff."""
144
+ output = run_git_command(
145
+ repo_root,
146
+ [
147
+ "ls-files",
148
+ "--others",
149
+ "--exclude-standard",
150
+ "--",
151
+ *_ASSERTION_EXCLUDED_PATHSPECS,
152
+ ],
153
+ )
154
+ return [
155
+ _build_untracked_diff(repo_root, rel_path)
156
+ for rel_path in output.splitlines()
157
+ if rel_path
158
+ ]
159
+
160
+
161
+ def _join_diffs(parts: list[str]) -> str:
162
+ parts = [_ensure_trailing_newline(p) for p in parts if p]
163
+ return "".join(parts)
164
+
165
+
166
+ def get_uncommitted_diff(repo_root: Path) -> str:
167
+ """Build a diff of **only uncommitted** changes (staged + unstaged +
168
+ untracked).
169
+
170
+ ``git diff HEAD`` compares the working tree directly to ``HEAD``, so
171
+ local commits are excluded — only changes the agent hasn't committed yet
172
+ appear.
173
+ """
174
+ try:
175
+ tracked = _build_tracked_diff(repo_root, "HEAD")
176
+ untracked = _build_untracked_diffs(repo_root)
177
+ return _join_diffs([tracked, *untracked])
178
+ except RuntimeError as exc:
179
+ exit_with_error(f"Failed to collect uncommitted diff: {exc}")
180
+
181
+
182
+ def get_full_diff(repo_root: Path, base_sha: str) -> str:
183
+ """Build a unified diff from ``base_sha`` to the current working tree.
184
+
185
+ ``git diff <base_sha>`` captures everything since the base commit —
186
+ local commits, staged changes, unstaged changes, and untracked files.
187
+ This is the diff the backend uses to reconstruct the full working state
188
+ (for terminal/browser sandboxes).
189
+ """
190
+ try:
191
+ tracked = _build_tracked_diff(repo_root, base_sha)
192
+ untracked = _build_untracked_diffs(repo_root)
193
+ return _join_diffs([tracked, *untracked])
194
+ except RuntimeError as exc:
195
+ exit_with_error(f"Failed to collect full diff: {exc}")
196
+
197
+
198
+ def _ensure_trailing_newline(value: str) -> str:
199
+ return value if value.endswith("\n") else value + "\n"
@@ -14,9 +14,11 @@ from bundle import build_bundle
14
14
  from git import (
15
15
  exit_with_error,
16
16
  find_git_root,
17
+ get_full_diff,
17
18
  get_head_branch,
18
- get_head_sha,
19
+ get_merge_base,
19
20
  get_uncommitted_diff,
21
+ get_upstream_ref,
20
22
  )
21
23
  from link import load_link, save_link
22
24
  from models import CheckpointResponse, SessionStatus, render_stack_list
@@ -121,10 +123,11 @@ def _write_full_file(path: Path, content: str, repo_root: Path) -> None:
121
123
 
122
124
 
123
125
  def _refresh_skill_files(repo_root: Path) -> None:
124
- """Write/refresh the assertion-owned skill files for each coding agent.
126
+ """Overwrite the assertion-owned skill files with the bundled template.
125
127
 
126
- Safe to call on every checkpoint these files are entirely ours; updating
127
- them to match the installed CLI version cannot clobber user content.
128
+ Called only from `asrt install` (the one-time onboarding step), never on a
129
+ regular agent run these files are entirely ours, so writing them to match
130
+ the installed CLI version cannot clobber user content.
128
131
 
129
132
  `.claude/skills/` — Claude Code
130
133
  `.agents/skills/` — Codex + Cursor (Cursor also accepts .cursor/skills/)
@@ -142,6 +145,55 @@ def _refresh_skill_files(repo_root: Path) -> None:
142
145
  )
143
146
 
144
147
 
148
+ # Top-level agent instruction files. Unlike the skill files (which live in
149
+ # agent-owned `*/skills/` dirs), these are user-owned docs that may already hold
150
+ # the user's own content. So we inject an HTML-comment-delimited block rather
151
+ # than overwriting the whole file, and replace just that block on re-install.
152
+ _AGENT_DOC_FILES = ("AGENTS.md", "CLAUDE.md")
153
+ _AGENT_BLOCK_START = "<!-- assertion-cli:start -->"
154
+ _AGENT_BLOCK_END = "<!-- assertion-cli:end -->"
155
+
156
+
157
+ def _agent_doc_block() -> str:
158
+ # No trailing newline: callers add exactly one when assembling the file so
159
+ # re-installs stay byte-idempotent (a block followed by the original
160
+ # post-marker text mustn't accumulate blank lines).
161
+ body = _load_template("AGENTS.md").strip("\n")
162
+ return f"{_AGENT_BLOCK_START}\n{body}\n{_AGENT_BLOCK_END}"
163
+
164
+
165
+ def _upsert_agent_block(path: Path, block: str, repo_root: Path) -> None:
166
+ """Insert/replace the Assertion block in a user-owned agent doc, idempotently.
167
+
168
+ Creates the file from the block if missing; replaces an existing block in
169
+ place; otherwise appends the block, leaving the user's content untouched.
170
+ """
171
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
172
+
173
+ if _AGENT_BLOCK_START in existing and _AGENT_BLOCK_END in existing:
174
+ pre = existing.split(_AGENT_BLOCK_START, 1)[0]
175
+ post = existing.split(_AGENT_BLOCK_END, 1)[1]
176
+ prefix = pre.rstrip("\n") + "\n\n" if pre.strip() else ""
177
+ new_content = prefix + block + post
178
+ elif existing.strip():
179
+ new_content = existing.rstrip("\n") + "\n\n" + block + "\n"
180
+ else:
181
+ new_content = block + "\n"
182
+
183
+ if existing == new_content:
184
+ return
185
+ path.parent.mkdir(parents=True, exist_ok=True)
186
+ path.write_text(new_content, encoding="utf-8")
187
+ typer.echo(f"Wrote {path.relative_to(repo_root)}")
188
+
189
+
190
+ def _write_agent_docs(repo_root: Path) -> None:
191
+ """Write the assertion-cli pointer into the top-level agent instruction files."""
192
+ block = _agent_doc_block()
193
+ for name in _AGENT_DOC_FILES:
194
+ _upsert_agent_block(repo_root / name, block, repo_root)
195
+
196
+
145
197
  _ASSERTION_GITIGNORE_RULES = (".assertion/*", "!.assertion/seed.py")
146
198
 
147
199
 
@@ -175,33 +227,77 @@ def _ensure_gitignore_limits_assertion_state(repo_root: Path) -> None:
175
227
  )
176
228
 
177
229
 
178
- @app.command("init")
179
- def init() -> None:
180
- """One-time bootstrap: install the assertion-cli skill files into this repo.
230
+ @app.command("install")
231
+ def install() -> None:
232
+ """One-time onboarding: scaffold the Assertion skill + agent docs into this repo.
181
233
 
182
- Writes `.claude/skills/assertion-cli/SKILL.md` and
183
- `.agents/skills/assertion-cli/SKILL.md`. Run this once after
184
- `uv tool install assertion-cli` so the coding agent (Claude Code, Codex, Cursor)
185
- auto-discovers the skill from its skills directory and knows to call `asrt`.
186
- `asrt checkpoint` refreshes these skill files on each run so they stay aligned
187
- with the installed CLI.
234
+ Run this once after `uv tool install assertion-cli`. It writes:
188
235
 
189
- `init` never touches `CLAUDE.md` or `AGENTS.md`. Those are user-owned files;
190
- the skill files are auto-discovered without an activation pointer, and editing
191
- user-tracked docs would dirty their working tree and leak the assertion
192
- bootstrap into the verification diff (which is anchored to the HEAD recorded
193
- below).
236
+ - `.claude/skills/assertion-cli/SKILL.md` and
237
+ `.agents/skills/assertion-cli/SKILL.md` the skill the coding agent
238
+ (Claude Code, Codex, Cursor) auto-discovers so it knows to call `asrt`.
239
+ - A short assertion-cli pointer block in the top-level `AGENTS.md` and
240
+ `CLAUDE.md`. These are user-owned, so the block is delimited by HTML
241
+ comments and re-installs replace just that block — any surrounding
242
+ content the user wrote is preserved.
243
+ - `.gitignore` rules that ignore local `.assertion/*` run state while
244
+ keeping committed seed hooks trackable.
194
245
 
195
- Also records the current HEAD SHA as the diff base for all future checkpoint/verify
196
- calls in this repo. The base is what the backend `git checkout`s against before
197
- applying the diff capturing it once at init means later commands don't need to
198
- rediscover it (which previously broke in sandboxes with no remote-tracking refs).
246
+ This is the onboarding step, not a per-run step: skill files are written
247
+ here, never on `asrt checkpoint`. After installing, run `asrt session start`
248
+ to record the diff base for a work session.
199
249
  """
200
250
  repo_root = find_git_root(Path.cwd())
201
251
  _refresh_skill_files(repo_root)
252
+ _write_agent_docs(repo_root)
253
+ _ensure_gitignore_limits_assertion_state(repo_root)
254
+
255
+ typer.echo("")
256
+ typer.echo(
257
+ "Assertion skill installed. To start a work session, run `asrt session start`."
258
+ )
259
+
260
+
261
+ session_app = typer.Typer(help="Manage Assertion work sessions.")
262
+ app.add_typer(session_app, name="session")
263
+
264
+
265
+ def _resolve_diff_base(repo_root: Path) -> str:
266
+ """Compute the merge-base with the upstream tracking branch.
267
+
268
+ Errors out when no upstream can be resolved (detached HEAD, no tracking
269
+ branch, branch not pushed to remote).
270
+ """
271
+ upstream = get_upstream_ref(repo_root)
272
+ if upstream is None:
273
+ exit_with_error(
274
+ "Cannot determine the diff base: no upstream tracking branch found.\n"
275
+ "Make sure you are on a branch that has been pushed to a remote "
276
+ "with a tracking branch configured, e.g.:\n"
277
+ " git push -u origin <branch>\n"
278
+ "Or use an existing branch that tracks an upstream.",
279
+ )
280
+ return get_merge_base(repo_root, upstream)
281
+
282
+
283
+ @session_app.command("start")
284
+ def session_start() -> None:
285
+ """Record the merge-base with the upstream tracking branch as the diff base.
286
+
287
+ Unlike the old HEAD-based approach, the merge-base always exists on the
288
+ remote so the backend can check it out. Branches without a remote tracking
289
+ ref (detached HEAD, no upstream, not pushed) are rejected with a clear
290
+ message — run ``asrt session start`` from a branch with an upstream.
291
+
292
+ `session start` does not write skill files — that is `asrt install`, the
293
+ one-time onboarding step. It also never touches user content in `CLAUDE.md` /
294
+ `AGENTS.md`. It does ensure the `.gitignore` rules exist so the `.assertion`
295
+ state it writes below stays out of the user's commits.
296
+ """
297
+ repo_root = find_git_root(Path.cwd())
202
298
  _ensure_gitignore_limits_assertion_state(repo_root)
203
299
 
204
- base_sha = get_head_sha(repo_root)
300
+ base_sha = _resolve_diff_base(repo_root)
205
301
  assertion_dir = repo_root / ASSERTION_DIR_NAME
206
302
  assertion_dir.mkdir(exist_ok=True)
207
303
  base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
@@ -219,9 +315,9 @@ def init() -> None:
219
315
 
220
316
  @app.command("new")
221
317
  def new() -> None:
222
- """Reset local Assertion run state and record current HEAD as the diff base."""
318
+ """Reset local Assertion run state and record the merge-base as the diff base."""
223
319
  repo_root = find_git_root(Path.cwd())
224
- base_sha = get_head_sha(repo_root)
320
+ base_sha = _resolve_diff_base(repo_root)
225
321
  assertion_dir = repo_root / ASSERTION_DIR_NAME
226
322
  assertion_dir.mkdir(exist_ok=True)
227
323
 
@@ -296,12 +392,6 @@ def checkpoint(
296
392
 
297
393
  repo_root = find_git_root(Path.cwd())
298
394
 
299
- # Refresh the assertion-owned skill files so the agent always loads the
300
- # version that ships with the installed CLI. The CLI never writes
301
- # CLAUDE.md / AGENTS.md — those are user-owned, and the skill files are
302
- # auto-discovered without an activation pointer.
303
- _refresh_skill_files(repo_root)
304
-
305
395
  existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
306
396
  session_exists = existing_metadata_path.exists()
307
397
 
@@ -332,13 +422,10 @@ def checkpoint(
332
422
  assert stack_id is not None
333
423
 
334
424
  append_checkpoint_entry(ctx.checkpoint_path, message)
335
- # Base SHA is whatever `asrt init` recorded for this repo. Backend uses
336
- # it as the checkout target before applying the diff. Pinning it at init
337
- # time (instead of rediscovering per command) means sandboxes without
338
- # remote-tracking refs — e.g. Codex Cloud — still work.
339
425
  base_sha = read_init_base_sha(ctx.assertion_dir)
340
426
  head_branch = get_head_branch(ctx.repo_root)
341
- diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
427
+ diff_text = get_full_diff(ctx.repo_root, base_sha)
428
+ uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
342
429
  metadata = update_metadata_anchor(
343
430
  ctx.metadata_path, metadata, base_sha, head_branch=head_branch
344
431
  )
@@ -346,6 +433,7 @@ def checkpoint(
346
433
  bundle_bytes = build_bundle(
347
434
  metadata=metadata,
348
435
  diff_text=diff_text,
436
+ uncommitted_diff_text=uncommitted_diff_text,
349
437
  prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
350
438
  checkpoint_text=message,
351
439
  )
@@ -436,11 +524,10 @@ def verify(
436
524
  ctx, metadata = load_existing_session(Path.cwd())
437
525
  stack_id = metadata.stack_id
438
526
  assert stack_id is not None
439
- # See checkpoint() for why the base is the SHA `asrt init` recorded,
440
- # not local HEAD.
441
527
  base_sha = read_init_base_sha(ctx.assertion_dir)
442
528
  head_branch = get_head_branch(ctx.repo_root)
443
- diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
529
+ diff_text = get_full_diff(ctx.repo_root, base_sha)
530
+ uncommitted_diff_text = get_uncommitted_diff(ctx.repo_root)
444
531
  metadata = update_metadata_anchor(
445
532
  ctx.metadata_path, metadata, base_sha, head_branch=head_branch
446
533
  )
@@ -448,6 +535,7 @@ def verify(
448
535
  bundle_bytes = build_bundle(
449
536
  metadata=metadata,
450
537
  diff_text=diff_text,
538
+ uncommitted_diff_text=uncommitted_diff_text,
451
539
  prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
452
540
  checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
453
541
  )
@@ -64,7 +64,7 @@ class ErrorResponse(BaseModel):
64
64
  class MetadataPayload(BaseModel):
65
65
  """Local on-disk schema for `.assertion/metadata.json`.
66
66
 
67
- `base_sha` is the diff base (the commit `asrt init` recorded; the commit
67
+ `base_sha` is the diff base (the commit `asrt session start` recorded; the commit
68
68
  the backend checks out before applying the diff). In the bundle sent to
69
69
  the backend it is remapped to `head_sha` for wire compatibility — from
70
70
  the backend's perspective, after `git checkout <sha>`, that SHA IS its
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.7"
7
+ version = "0.5.9"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -17,30 +17,32 @@ BASE_SHA_FILE_NAME = "base_sha"
17
17
 
18
18
 
19
19
  def read_init_base_sha(assertion_dir: Path) -> str:
20
- """Return the diff base SHA recorded by `asrt init`.
20
+ """Return the diff base SHA recorded by `asrt session start`.
21
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.
22
+ The base SHA is the merge-base with the upstream tracking branch always
23
+ fetchable from the remote. Every checkpoint/verify reads this value; it
24
+ never changes within a session.
25
25
  """
26
26
  path = assertion_dir / BASE_SHA_FILE_NAME
27
27
  if not path.exists():
28
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."
29
+ "No diff base recorded for this repo. Run `asrt session start` "
30
+ "first — it captures the merge-base SHA that checkpoint/verify "
31
+ "diff against."
31
32
  )
32
33
  content = path.read_text(encoding="utf-8").strip()
33
34
  if not content:
34
35
  exit_with_error(
35
- f"{path.name} is empty. Re-run `asrt init` to record the diff base."
36
+ f"{path.name} is empty. Re-run `asrt session start` to record the "
37
+ "diff base."
36
38
  )
37
39
  return content
38
40
 
39
41
 
40
42
  def _require_repo_ready(start_path: Path) -> Path:
41
43
  repo_root = find_git_root(start_path)
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
+ # Fail fast if `asrt session start` hasn't run — checkpoint/verify need the
45
+ # base SHA it records before doing any work or making any network calls.
44
46
  read_init_base_sha(repo_root / ASSERTION_DIR_NAME)
45
47
  return repo_root
46
48
 
@@ -193,8 +195,8 @@ def update_metadata_anchor(
193
195
  ) -> MetadataPayload:
194
196
  """Refresh the per-session git anchor: diff base + branch name.
195
197
 
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
+ `base_sha` is the diff base (recorded by `asrt session start`, same value on
199
+ every call within a session). `head_branch` is the current local branch — may
198
200
  legitimately change within a session if the user renames or switches.
199
201
  """
200
202
  updated = metadata.model_copy(
@@ -0,0 +1,14 @@
1
+ # Assertion verification
2
+
3
+ This repo uses the Assertion CLI (`asrt`) to track user intent and verify your work. The full workflow lives in the `assertion-cli` skill:
4
+
5
+ - **Claude Code** — [`.claude/skills/assertion-cli/SKILL.md`](.claude/skills/assertion-cli/SKILL.md)
6
+ - **Codex / Cursor** — [`.agents/skills/assertion-cli/SKILL.md`](.agents/skills/assertion-cli/SKILL.md)
7
+
8
+ Both files are byte-identical — read whichever your agent loads (or either one directly). In brief, follow these three rules for every conversation:
9
+
10
+ 1. **Every user message:** call `asrt prompt "<exact text of the message>"` before doing any other work for that turn. Apply to every user turn, not just the first.
11
+ 2. **Checkpoint at trajectory-feedback moments** with `asrt checkpoint` — a handful across a feature build, not one per file.
12
+ 3. **Verify at completion** with `asrt verify`. Once it is clean, open a PR whose body embeds the hidden session marker from `asrt get-link`.
13
+
14
+ See the skill file for the full rules, edge cases, and the exact PR command.
@@ -14,7 +14,8 @@ 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
- - `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.
17
+ - `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.
18
+ - `asrt session start` has been run for this work session. It records the current HEAD as the diff base that every checkpoint/verify diffs against. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt session start` and retry.
18
19
 
19
20
  **First command sequence for a new work session:**
20
21
 
@@ -118,7 +119,7 @@ When you pass `--stack`, briefly state which stack you chose and why ("Picked st
118
119
  | `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>`. |
119
120
  | `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>"`, or ask the user to run `asrt new` if this should be a fresh session. |
120
121
  | `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. |
121
- | `No diff base recorded for this repo.` | `asrt init` wasn't run. | Run `asrt init` and retry. |
122
+ | `No diff base recorded for this repo.` | `asrt session start` wasn't run. | Run `asrt session start` and retry. |
122
123
  | `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
123
124
 
124
125
  ---
@@ -15,6 +15,7 @@ def test_build_bundle_writes_session_metadata() -> None:
15
15
  bundle_bytes = build_bundle(
16
16
  metadata=metadata,
17
17
  diff_text="diff --git a/file b/file",
18
+ uncommitted_diff_text="diff --git a/file b/file",
18
19
  prompts_text='{"ts": "2026-05-28T14:32:01Z", "text": "Do the thing"}\n',
19
20
  checkpoint_text="Checkpoint text",
20
21
  )
@@ -31,3 +32,10 @@ def test_build_bundle_writes_session_metadata() -> None:
31
32
 
32
33
  prompts_raw = zf.read(".assertion/prompts").decode("utf-8")
33
34
  assert '"text": "Do the thing"' in prompts_raw
35
+
36
+ # Both diffs are included in the bundle.
37
+ git_diff = zf.read("git.diff").decode("utf-8")
38
+ assert "diff --git a/file b/file" in git_diff
39
+
40
+ uncommitted_diff = zf.read("uncommitted.diff").decode("utf-8")
41
+ assert "diff --git a/file b/file" in uncommitted_diff