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.
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/PKG-INFO +3 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/README.md +2 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/PKG-INFO +3 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/SOURCES.txt +4 -2
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/bundle.py +3 -0
- assertion_cli-0.5.9/git.py +199 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/main.py +127 -39
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/models.py +1 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/pyproject.toml +1 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/session.py +13 -11
- assertion_cli-0.5.9/templates/AGENTS.md +14 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/templates/SKILL.md +3 -2
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_bundle.py +8 -0
- assertion_cli-0.5.9/tests/test_git.py +478 -0
- assertion_cli-0.5.9/tests/test_install.py +155 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_new.py +29 -11
- assertion_cli-0.5.9/tests/test_session_start.py +251 -0
- assertion_cli-0.5.7/git.py +0 -126
- assertion_cli-0.5.7/tests/test_git.py +0 -257
- assertion_cli-0.5.7/tests/test_init.py +0 -320
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/api.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/link.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/setup.cfg +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/templates/__init__.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_api.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_link.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_main.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.9}/tests/test_prompt.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
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
|
-
|
|
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
|
-
"""
|
|
126
|
+
"""Overwrite the assertion-owned skill files with the bundled template.
|
|
125
127
|
|
|
126
|
-
|
|
127
|
-
|
|
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("
|
|
179
|
-
def
|
|
180
|
-
"""One-time
|
|
230
|
+
@app.command("install")
|
|
231
|
+
def install() -> None:
|
|
232
|
+
"""One-time onboarding: scaffold the Assertion skill + agent docs into this repo.
|
|
181
233
|
|
|
182
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
@@ -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
|
|
20
|
+
"""Return the diff base SHA recorded by `asrt session start`.
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
30
|
-
"it captures the
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|