assertion-cli 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {assertion_cli-0.2.0.dist-info → assertion_cli-0.3.0.dist-info}/METADATA +1 -1
- assertion_cli-0.3.0.dist-info/RECORD +15 -0
- assertion_cli_templates/ACTIVATION.md +1 -1
- assertion_cli_templates/SKILL.md +37 -46
- bundle.py +17 -1
- git.py +0 -101
- main.py +94 -37
- models.py +12 -1
- session.py +34 -52
- assertion_cli-0.2.0.dist-info/RECORD +0 -15
- {assertion_cli-0.2.0.dist-info → assertion_cli-0.3.0.dist-info}/WHEEL +0 -0
- {assertion_cli-0.2.0.dist-info → assertion_cli-0.3.0.dist-info}/entry_points.txt +0 -0
- {assertion_cli-0.2.0.dist-info → assertion_cli-0.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
api.py,sha256=6RuyOoZrsBTY_51XA60iHwkYJ6nqME0CrmDgGaEbWzk,6901
|
|
2
|
+
bundle.py,sha256=O616zO-SY66J0v2d1NAr3S7cshSmUIAUiYX_sI_dMYo,1515
|
|
3
|
+
git.py,sha256=nxIdwy4i9pQ-40BnFO4_sLOdQK3zUHsYG17cmXZj8uE,4010
|
|
4
|
+
link.py,sha256=bfH0MhkeTFGbOSJbSvuuw3ilGX3akyvjvCw25AP_fz8,643
|
|
5
|
+
main.py,sha256=igQOML1A6WlSFD8SWM5P7WxnuwsWxMMvwp7knxj4DpA,20759
|
|
6
|
+
models.py,sha256=ApnfCO7CF5Nfdy5-ho7U-YRLWM-C3421fAT7GXa4Y30,2269
|
|
7
|
+
session.py,sha256=3oe3GbAaA2ynjk-2DFsLvwlFUMwHNnUmTKkBrQo_8IE,6507
|
|
8
|
+
assertion_cli_templates/ACTIVATION.md,sha256=5Z63TiInCe_vJEhOkqiKb1S4LRJHKy4Wx-c5yUb8p8M,1608
|
|
9
|
+
assertion_cli_templates/SKILL.md,sha256=zE1QB-vRIzqxHAvCZI1VsE0S2fb7cWb_ynPwzATg9no,13260
|
|
10
|
+
assertion_cli_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
assertion_cli-0.3.0.dist-info/METADATA,sha256=aGRIOYEii33yusuUfejxJCHsf93cxs7xDDy13n8sIes,1712
|
|
12
|
+
assertion_cli-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
assertion_cli-0.3.0.dist-info/entry_points.txt,sha256=LgwuPLZEIk-4sD7ghGVRQOhm3PG8sPVozMRxrihwMgU,34
|
|
14
|
+
assertion_cli-0.3.0.dist-info/top_level.txt,sha256=xbZYH9xQOa99z-Vbkc2Xp6h8oUR97DVlyczy8H4ZuFc,64
|
|
15
|
+
assertion_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -8,7 +8,7 @@ This repo uses the Assertion CLI (`asrt`) to track user intent and verify your w
|
|
|
8
8
|
Both files are byte-identical. Read whichever your agent loads (or either one directly). In brief, follow these three rules for every conversation:
|
|
9
9
|
|
|
10
10
|
1. **Every user message:** call `asrt prompt "<exact text of the message>"` before doing any other work for that turn. This is the verifier's source of truth for what the user asked for and how their direction evolves. Apply to every user turn, not just the first.
|
|
11
|
-
2. **Checkpoint at trajectory-feedback moments**, not on every small change.
|
|
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`.
|
assertion_cli_templates/SKILL.md
CHANGED
|
@@ -14,11 +14,11 @@ This skill is operational, not advisory. Treat each rule as a hard requirement u
|
|
|
14
14
|
Before any `asrt` call:
|
|
15
15
|
|
|
16
16
|
- You are inside a git repository.
|
|
17
|
-
-
|
|
17
|
+
- `asrt init` has been run in this repo. It records the current HEAD as the diff base that every checkpoint/verify diffs against, and installs the skill files you are reading right now. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt init` and retry.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually — use `asrt prompt` to append to the prompts log.
|
|
20
20
|
|
|
21
|
-
`asrt stacks` is read-only and lists
|
|
21
|
+
`asrt stacks` is read-only and lists every verification stack in the user's workspace, along with the repo each is configured for. Run it once per session before your first checkpoint so you can pick a stack ID; do not hardcode IDs from memory.
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -59,20 +59,20 @@ This applies to **every user turn**, not just the first. It applies even when th
|
|
|
59
59
|
|
|
60
60
|
**Form:**
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
The first checkpoint of a session needs a stack ID; subsequent ones don't:
|
|
63
|
+
|
|
63
64
|
```
|
|
64
|
-
asrt checkpoint "<what you built and
|
|
65
|
+
asrt checkpoint --stack <STACK_ID> "<what you built and decided>" # first checkpoint
|
|
66
|
+
asrt checkpoint "<what you built and decided>" # 2nd+ checkpoints
|
|
65
67
|
```
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
- A session already exists → continue it.
|
|
70
|
-
|
|
71
|
-
If no stack is attached to this repo on the very first checkpoint, the command fails with a clear message telling the user to attach one in the Assertion web app. If multiple stacks are attached, it tells you to pass `--stack <id>` to disambiguate.
|
|
69
|
+
How the CLI decides:
|
|
70
|
+
- `.assertion/metadata.json` doesn't exist yet → no session. You must pass `--stack <id>`. The CLI errors with a clear message if you don't.
|
|
71
|
+
- A session already exists → continue it. Don't pass `--stack` (it would overwrite the in-flight session).
|
|
72
72
|
|
|
73
|
-
Flags exist for explicit intent
|
|
73
|
+
Flags exist for explicit intent:
|
|
74
74
|
- `--continue` — fail fast if no session exists (otherwise behaves identically to no flag when a session does exist).
|
|
75
|
-
- `--stack <id>` —
|
|
75
|
+
- `--stack <id>` — anchor the new session to a specific stack. **Required on the first checkpoint, rejected once a session exists.** The stack is locked at session creation; re-anchoring would create a split verification record, so the CLI errors if you try. Only the user can reset a session (by clearing `.assertion/`).
|
|
76
76
|
|
|
77
77
|
Use checkpoint messages to summarize substantive progress and decisions since the previous checkpoint — not a changelog of every line touched.
|
|
78
78
|
|
|
@@ -82,41 +82,31 @@ Use checkpoint messages to summarize substantive progress and decisions since th
|
|
|
82
82
|
- If failed: treat the feedback as authoritative for the next slice of work, address it, then checkpoint again. Do not push toward verify with failing feedback unaddressed.
|
|
83
83
|
- Optionally, after a failed checkpoint, record your sentiment with `asrt decision --yes <checkpoint-id>` (you agree with the feedback) or `asrt decision --no <checkpoint-id>` (you disagree). Do not call `decision` for successful checkpoints.
|
|
84
84
|
|
|
85
|
-
### Stack selection —
|
|
86
|
-
|
|
87
|
-
Stack selection is **enforced by the CLI**, not by you. When you run `asrt checkpoint "..."` (no `--stack` flag), the CLI:
|
|
88
|
-
|
|
89
|
-
1. Runs `git remote get-url origin` and normalizes to `owner/name`
|
|
90
|
-
2. Calls the backend, fetches every stack in the user's workspace, and matches by the stack's `repo` field
|
|
91
|
-
3. Picks the single match → starts the session against it
|
|
92
|
-
4. If zero or multiple match, exits non-zero with a clear, user-facing error
|
|
85
|
+
### Stack selection — your judgment, not the CLI's
|
|
93
86
|
|
|
94
|
-
You
|
|
87
|
+
**You pick the stack.** Before the first checkpoint, run `asrt stacks` to see every stack in the user's workspace. Each line includes the stack's `id`, `name`, `description`, and the `repo` it's configured for. Pick the one that best fits this repo and the work in flight, then pass it as `--stack <id>` on the first checkpoint.
|
|
95
88
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
This is the most common failure. The CLI distinguishes three cases — your response is the same shape in all of them:
|
|
99
|
-
|
|
100
|
-
| CLI error starts with | Meaning |
|
|
101
|
-
|---|---|
|
|
102
|
-
| `No verification stacks exist in this workspace yet.` | User has never set up any stacks. |
|
|
103
|
-
| `No verification stack is attached to this repo (...)` | Stacks exist but none are bound to this repo. |
|
|
104
|
-
| `Multiple stacks are attached to ...` | Two or more stacks match; CLI asks for `--stack <id>`. |
|
|
89
|
+
Signals to use, in roughly decreasing order of strength:
|
|
105
90
|
|
|
106
|
-
**
|
|
91
|
+
1. **Stack `repo` field vs. this repo's identity.** A stack whose `repo` matches the local checkout (folder name, `git remote -v` if present, `git config --get remote.origin.url`, the `name` in `package.json` / `pyproject.toml` / `Cargo.toml`, the top heading in `README.md`) is almost always the right choice.
|
|
92
|
+
2. **Stack `name` / `description` vs. the work.** When more than one stack could plausibly fit the repo, pick the one whose name/description best matches the primary deliverable (e.g., "PR Code Quality" for refactors, "Security Review" for auth/permissions work, "Docs Audit" for documentation passes). The user's prompt log is your guide to what they actually asked for.
|
|
93
|
+
3. **Branch name.** A branch named `feat/billing-…` against a stack named `Billing QA` is a strong signal.
|
|
107
94
|
|
|
108
|
-
|
|
109
|
-
2. **Keep the work moving on your side.** You can continue editing files and implementing what the user asked for. Verification is the gate before opening a PR — not a gate on writing code.
|
|
110
|
-
3. Keep calling `asrt prompt "..."` on every new user turn. The prompt log accumulates in `.assertion/prompts` and is *not* lost when checkpoint fails — once the user attaches a stack, the next successful `asrt checkpoint "..."` ships every prompt logged so far in its bundle.
|
|
111
|
-
4. When the user says they've attached a stack (or asks you to retry), call `asrt checkpoint "<what you built so far>"` again. The CLI will resolve the stack this time and start the session with the full prompt history already in the bundle.
|
|
95
|
+
If none of the stacks plausibly fit this repo, surface that to the user verbatim — do not guess. They likely need to attach a stack in the Assertion web app first. Keep recording prompts; the prompt log persists and ships in the bundle once the user picks a stack and you successfully checkpoint.
|
|
112
96
|
|
|
113
|
-
|
|
97
|
+
When you pass `--stack`, briefly state which stack you chose and why ("Picked stack `abc123` — its `repo` matches and the description fits the auth work the user described"). That gives the user one chance to redirect before the session is anchored.
|
|
114
98
|
|
|
115
|
-
**
|
|
99
|
+
**Never** hardcode a stack ID from memory, copy one from a different repo, or pick one whose `repo` field clearly points elsewhere just to get a session started. A wrong stack creates a verification record in the wrong context, which is worse than no session at all.
|
|
116
100
|
|
|
117
|
-
###
|
|
101
|
+
### When `asrt checkpoint` errors
|
|
118
102
|
|
|
119
|
-
|
|
103
|
+
| CLI error starts with | What it means | What to do |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `No session exists yet and no --stack given.` | First checkpoint of a fresh `.assertion/`, but you didn't pick a stack. | Run `asrt stacks`, pick one using the signals above, pass `--stack <id>`. |
|
|
106
|
+
| `This session is already anchored to a stack.` | You passed `--stack` after a session was already created. The CLI locks the stack at session creation. | Drop the `--stack` flag and re-run `asrt checkpoint "<message>"`. |
|
|
107
|
+
| `ERROR: Unknown stack '<id>'.` | The `--stack` you passed is not in the workspace. | Re-run `asrt stacks` (the list may have changed) and pass a valid id. |
|
|
108
|
+
| `No diff base recorded for this repo.` | `asrt init` wasn't run. | Run `asrt init` and retry. |
|
|
109
|
+
| `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
|
|
120
110
|
|
|
121
111
|
---
|
|
122
112
|
|
|
@@ -164,13 +154,14 @@ done
|
|
|
164
154
|
## Quick reference
|
|
165
155
|
|
|
166
156
|
```
|
|
167
|
-
asrt prompt "<message>"
|
|
168
|
-
asrt stacks
|
|
169
|
-
asrt checkpoint "..."
|
|
170
|
-
asrt
|
|
171
|
-
asrt
|
|
172
|
-
asrt verify
|
|
173
|
-
asrt
|
|
157
|
+
asrt prompt "<message>" # every user turn, before anything else
|
|
158
|
+
asrt stacks # list every stack — read once before first checkpoint
|
|
159
|
+
asrt checkpoint --stack <id> "..." # first checkpoint of a session
|
|
160
|
+
asrt checkpoint "..." # subsequent checkpoints — auto-continues
|
|
161
|
+
asrt decision --yes|--no <ckpt-id> # optional, on failed checkpoints only
|
|
162
|
+
asrt verify # submit verification (non-blocking)
|
|
163
|
+
asrt verify-status # one-shot poll; loop with your own sleep
|
|
164
|
+
asrt get-link # retrieve the last session URL
|
|
174
165
|
```
|
|
175
166
|
|
|
176
167
|
## Environment
|
bundle.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import io
|
|
2
|
+
import json
|
|
2
3
|
import zipfile
|
|
3
4
|
|
|
4
5
|
from models import MetadataPayload
|
|
@@ -10,6 +11,21 @@ PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
|
|
|
10
11
|
CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _metadata_wire_json(metadata: MetadataPayload) -> str:
|
|
15
|
+
"""Serialize metadata for the bundle, remapping `base_sha` → `head_sha`.
|
|
16
|
+
|
|
17
|
+
The CLI stores the diff base as `base_sha` because that matches what the
|
|
18
|
+
field means from the CLI's perspective (the foundation the diff builds
|
|
19
|
+
on). The backend reads it as `head_sha` because after its `git checkout`
|
|
20
|
+
that SHA literally is the clone's HEAD. Same value, different name per
|
|
21
|
+
side; the remap lives here so neither side has to know about the other's
|
|
22
|
+
perspective.
|
|
23
|
+
"""
|
|
24
|
+
payload = metadata.model_dump()
|
|
25
|
+
payload["head_sha"] = payload.pop("base_sha", None)
|
|
26
|
+
return json.dumps(payload, indent=2) + "\n"
|
|
27
|
+
|
|
28
|
+
|
|
13
29
|
def build_bundle(
|
|
14
30
|
*,
|
|
15
31
|
metadata: MetadataPayload,
|
|
@@ -19,7 +35,7 @@ def build_bundle(
|
|
|
19
35
|
) -> bytes:
|
|
20
36
|
buf = io.BytesIO()
|
|
21
37
|
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
22
|
-
zf.writestr(METADATA_ARCHIVE_PATH, metadata
|
|
38
|
+
zf.writestr(METADATA_ARCHIVE_PATH, _metadata_wire_json(metadata))
|
|
23
39
|
zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
|
|
24
40
|
zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
|
|
25
41
|
zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
|
git.py
CHANGED
|
@@ -37,73 +37,6 @@ def find_git_root(start_path: Path) -> Path:
|
|
|
37
37
|
return Path(completed.stdout.strip())
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def get_diff_base_sha(repo_root: Path) -> str:
|
|
41
|
-
"""Return the SHA the verifier should diff against — i.e. the most recent
|
|
42
|
-
commit reachable from BOTH local HEAD and some `refs/remotes/*` ref.
|
|
43
|
-
|
|
44
|
-
The verifier clones from GitHub and runs `git checkout <head_sha>` (see
|
|
45
|
-
backend/repo_analysis/clone.py). That checkout fails for any commit that
|
|
46
|
-
only exists locally — which is exactly what Codex Cloud produces when its
|
|
47
|
-
agent autocommits before `asrt checkpoint`. By picking the latest commit
|
|
48
|
-
that's on origin AND reachable from HEAD, the checkout always succeeds;
|
|
49
|
-
any local-only commits on top of that base flow through as part of the
|
|
50
|
-
diff (see `get_uncommitted_diff`), so the verifier still sees the full
|
|
51
|
-
state the agent built.
|
|
52
|
-
|
|
53
|
-
When HEAD itself is on a remote-tracking ref (no local commits),
|
|
54
|
-
`git merge-base HEAD <ref>` returns HEAD, so this collapses to the old
|
|
55
|
-
behavior with no diff growth.
|
|
56
|
-
"""
|
|
57
|
-
try:
|
|
58
|
-
remote_refs = run_git_command(
|
|
59
|
-
repo_root, ["for-each-ref", "--format=%(refname:short)", "refs/remotes"]
|
|
60
|
-
)
|
|
61
|
-
except RuntimeError as exc:
|
|
62
|
-
exit_with_error(f"Failed to inspect remote refs: {exc}")
|
|
63
|
-
|
|
64
|
-
refs = [
|
|
65
|
-
ref for ref in remote_refs.splitlines() if ref and not ref.endswith("/HEAD")
|
|
66
|
-
]
|
|
67
|
-
if not refs:
|
|
68
|
-
exit_with_error(
|
|
69
|
-
"No remote-tracking branches found. The verifier needs a commit "
|
|
70
|
-
"on origin to apply the diff onto — push at least one branch "
|
|
71
|
-
"(and `git fetch`) before running this command."
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
candidate_shas: list[str] = []
|
|
75
|
-
for ref in refs:
|
|
76
|
-
completed = subprocess.run(
|
|
77
|
-
["git", "merge-base", "HEAD", ref],
|
|
78
|
-
cwd=repo_root,
|
|
79
|
-
capture_output=True,
|
|
80
|
-
text=True,
|
|
81
|
-
check=False,
|
|
82
|
-
)
|
|
83
|
-
if completed.returncode == 0:
|
|
84
|
-
sha = completed.stdout.strip()
|
|
85
|
-
if sha:
|
|
86
|
-
candidate_shas.append(sha)
|
|
87
|
-
|
|
88
|
-
if not candidate_shas:
|
|
89
|
-
exit_with_error(
|
|
90
|
-
"HEAD has no commits in common with any remote-tracking branch. "
|
|
91
|
-
"Push the branch (or its parent commits) to origin and retry."
|
|
92
|
-
)
|
|
93
|
-
|
|
94
|
-
best_sha = candidate_shas[0]
|
|
95
|
-
best_ts = -1
|
|
96
|
-
for sha in candidate_shas:
|
|
97
|
-
try:
|
|
98
|
-
ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", sha]))
|
|
99
|
-
except (RuntimeError, ValueError):
|
|
100
|
-
continue
|
|
101
|
-
if ts > best_ts:
|
|
102
|
-
best_ts = ts
|
|
103
|
-
best_sha = sha
|
|
104
|
-
return best_sha
|
|
105
|
-
|
|
106
|
-
|
|
107
40
|
def get_head_sha(repo_root: Path) -> str:
|
|
108
41
|
try:
|
|
109
42
|
return run_git_command(repo_root, ["rev-parse", "HEAD"])
|
|
@@ -120,40 +53,6 @@ def get_head_branch(repo_root: Path) -> str | None:
|
|
|
120
53
|
return name if name and name != "HEAD" else None
|
|
121
54
|
|
|
122
55
|
|
|
123
|
-
def get_origin_github_repo(repo_root: Path) -> str | None:
|
|
124
|
-
"""Return the current repo's GitHub `owner/name` from origin, or None if not parseable.
|
|
125
|
-
|
|
126
|
-
Accepts the common remote URL forms:
|
|
127
|
-
git@github.com:owner/name(.git)?
|
|
128
|
-
https://github.com/owner/name(.git)?
|
|
129
|
-
ssh://git@github.com/owner/name(.git)?
|
|
130
|
-
"""
|
|
131
|
-
try:
|
|
132
|
-
url = run_git_command(repo_root, ["remote", "get-url", "origin"]).strip()
|
|
133
|
-
except RuntimeError:
|
|
134
|
-
return None
|
|
135
|
-
if not url:
|
|
136
|
-
return None
|
|
137
|
-
|
|
138
|
-
if url.startswith("git@github.com:"):
|
|
139
|
-
path = url[len("git@github.com:") :]
|
|
140
|
-
elif url.startswith("ssh://git@github.com/"):
|
|
141
|
-
path = url[len("ssh://git@github.com/") :]
|
|
142
|
-
elif url.startswith("https://github.com/"):
|
|
143
|
-
path = url[len("https://github.com/") :]
|
|
144
|
-
elif url.startswith("http://github.com/"):
|
|
145
|
-
path = url[len("http://github.com/") :]
|
|
146
|
-
else:
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
path = path.rstrip("/")
|
|
150
|
-
if path.endswith(".git"):
|
|
151
|
-
path = path[: -len(".git")]
|
|
152
|
-
if path.count("/") != 1 or not all(path.split("/")):
|
|
153
|
-
return None
|
|
154
|
-
return path
|
|
155
|
-
|
|
156
|
-
|
|
157
56
|
def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
158
57
|
completed = subprocess.run(
|
|
159
58
|
[
|
main.py
CHANGED
|
@@ -5,6 +5,7 @@ import re
|
|
|
5
5
|
from datetime import datetime, timezone
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
import httpx
|
|
8
9
|
import typer
|
|
9
10
|
|
|
10
11
|
from api import AssertionClient
|
|
@@ -12,21 +13,23 @@ from bundle import build_bundle
|
|
|
12
13
|
from git import (
|
|
13
14
|
exit_with_error,
|
|
14
15
|
find_git_root,
|
|
15
|
-
get_diff_base_sha,
|
|
16
16
|
get_head_branch,
|
|
17
|
+
get_head_sha,
|
|
17
18
|
get_uncommitted_diff,
|
|
18
19
|
)
|
|
19
20
|
from link import load_link, save_link
|
|
20
21
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
21
22
|
from session import (
|
|
22
23
|
ASSERTION_DIR_NAME,
|
|
24
|
+
BASE_SHA_FILE_NAME,
|
|
23
25
|
METADATA_FILE_NAME,
|
|
24
26
|
PROMPTS_FILE_NAME,
|
|
25
27
|
append_checkpoint_entry,
|
|
26
28
|
continue_session,
|
|
27
29
|
load_existing_session,
|
|
30
|
+
read_init_base_sha,
|
|
28
31
|
start_new_session,
|
|
29
|
-
|
|
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="
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
#
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
base_sha =
|
|
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 =
|
|
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
|
-
|
|
352
|
-
|
|
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
|
|
378
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
533
|
+
if screenshots_saved:
|
|
477
534
|
typer.echo(
|
|
478
535
|
f"\nScreenshots ({screenshot_count}) saved to "
|
|
479
536
|
f".assertion/screenshots/{session_id}/"
|
models.py
CHANGED
|
@@ -62,11 +62,22 @@ class ErrorResponse(BaseModel):
|
|
|
62
62
|
|
|
63
63
|
|
|
64
64
|
class MetadataPayload(BaseModel):
|
|
65
|
+
"""Local on-disk schema for `.assertion/metadata.json`.
|
|
66
|
+
|
|
67
|
+
`base_sha` is the diff base (the commit `asrt init` recorded; the commit
|
|
68
|
+
the backend checks out before applying the diff). In the bundle sent to
|
|
69
|
+
the backend it is remapped to `head_sha` for wire compatibility — from
|
|
70
|
+
the backend's perspective, after `git checkout <sha>`, that SHA IS its
|
|
71
|
+
HEAD, so the name reads correctly on the consumer side. Locally we keep
|
|
72
|
+
`base_sha` because it matches what the CLI stores (`.assertion/base_sha`)
|
|
73
|
+
and what the value represents from the CLI's perspective.
|
|
74
|
+
"""
|
|
75
|
+
|
|
65
76
|
model_config = ConfigDict(extra="allow")
|
|
66
77
|
|
|
67
78
|
session_id: str
|
|
68
79
|
stack_id: str | None = None
|
|
69
|
-
|
|
80
|
+
base_sha: str | None = None
|
|
70
81
|
head_branch: str | None = None
|
|
71
82
|
|
|
72
83
|
|
session.py
CHANGED
|
@@ -6,8 +6,6 @@ from api import AssertionClient
|
|
|
6
6
|
from git import (
|
|
7
7
|
exit_with_error,
|
|
8
8
|
find_git_root,
|
|
9
|
-
get_diff_base_sha,
|
|
10
|
-
get_origin_github_repo,
|
|
11
9
|
)
|
|
12
10
|
from models import MetadataPayload, SessionContext, render_stack_list
|
|
13
11
|
|
|
@@ -15,13 +13,35 @@ ASSERTION_DIR_NAME = ".assertion"
|
|
|
15
13
|
PROMPTS_FILE_NAME = "prompts"
|
|
16
14
|
CHECKPOINT_FILE_NAME = "checkpoint"
|
|
17
15
|
METADATA_FILE_NAME = "metadata.json"
|
|
16
|
+
BASE_SHA_FILE_NAME = "base_sha"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_init_base_sha(assertion_dir: Path) -> str:
|
|
20
|
+
"""Return the diff base SHA recorded by `asrt init`.
|
|
21
|
+
|
|
22
|
+
Exits with a clear error if init hasn't been run or the file is empty.
|
|
23
|
+
The base SHA is the commit the backend `git checkout`s against before
|
|
24
|
+
applying the diff, so every checkpoint/verify must have it.
|
|
25
|
+
"""
|
|
26
|
+
path = assertion_dir / BASE_SHA_FILE_NAME
|
|
27
|
+
if not path.exists():
|
|
28
|
+
exit_with_error(
|
|
29
|
+
"No diff base recorded for this repo. Run `asrt init` first — "
|
|
30
|
+
"it captures the HEAD SHA that checkpoint/verify diff against."
|
|
31
|
+
)
|
|
32
|
+
content = path.read_text(encoding="utf-8").strip()
|
|
33
|
+
if not content:
|
|
34
|
+
exit_with_error(
|
|
35
|
+
f"{path.name} is empty. Re-run `asrt init` to record the diff base."
|
|
36
|
+
)
|
|
37
|
+
return content
|
|
18
38
|
|
|
19
39
|
|
|
20
40
|
def _require_repo_ready(start_path: Path) -> Path:
|
|
21
41
|
repo_root = find_git_root(start_path)
|
|
22
|
-
# Fail fast
|
|
23
|
-
#
|
|
24
|
-
|
|
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
|
|
188
|
+
def update_metadata_anchor(
|
|
213
189
|
metadata_path: Path,
|
|
214
190
|
metadata: MetadataPayload,
|
|
215
|
-
|
|
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={"
|
|
201
|
+
update={"base_sha": base_sha, "head_branch": head_branch}
|
|
220
202
|
)
|
|
221
203
|
metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
222
204
|
return updated
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
api.py,sha256=6RuyOoZrsBTY_51XA60iHwkYJ6nqME0CrmDgGaEbWzk,6901
|
|
2
|
-
bundle.py,sha256=e1-a0_hcKG0Xitjq0ac5u7ox3soAiodVbKIf4pRmI4w,828
|
|
3
|
-
git.py,sha256=IDiy5ZQPJwCFEecTabmcaHA_jEyM06Al3hnY1lKqyRM,7637
|
|
4
|
-
link.py,sha256=bfH0MhkeTFGbOSJbSvuuw3ilGX3akyvjvCw25AP_fz8,643
|
|
5
|
-
main.py,sha256=7SwKc8n7YkD6Jh5dNgs1ul5BSlClp-5RaDv2FMPDBis,18121
|
|
6
|
-
models.py,sha256=PQ0q_kUUzRVme325TXB_8eQaNl4LPSuxY8CVy9HK2ls,1678
|
|
7
|
-
session.py,sha256=or0SQPuqJILe2wkQT32wmqY2TTXt0l5medIF7oZovNs,7130
|
|
8
|
-
assertion_cli_templates/ACTIVATION.md,sha256=iDCSr87McqKX2ui1cQtKhk2Rcct04q_BIKAYpytaULo,1423
|
|
9
|
-
assertion_cli_templates/SKILL.md,sha256=YTHMN8PQtkVzZEmPRMighHZ4zakwv1kOKqM0wT01hYY,13059
|
|
10
|
-
assertion_cli_templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
-
assertion_cli-0.2.0.dist-info/METADATA,sha256=asC8-oqwe21zEdE8iPQSfB5kyXSFLIc3EuRzukdrkjY,1712
|
|
12
|
-
assertion_cli-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
-
assertion_cli-0.2.0.dist-info/entry_points.txt,sha256=LgwuPLZEIk-4sD7ghGVRQOhm3PG8sPVozMRxrihwMgU,34
|
|
14
|
-
assertion_cli-0.2.0.dist-info/top_level.txt,sha256=xbZYH9xQOa99z-Vbkc2Xp6h8oUR97DVlyczy8H4ZuFc,64
|
|
15
|
-
assertion_cli-0.2.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|