assertion-cli 0.5.5__tar.gz → 0.5.7__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.5 → assertion_cli-0.5.7}/PKG-INFO +3 -4
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/README.md +2 -3
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/PKG-INFO +3 -4
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/SOURCES.txt +0 -1
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/git.py +2 -2
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/main.py +39 -84
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/pyproject.toml +1 -1
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/templates/SKILL.md +15 -1
- assertion_cli-0.5.7/tests/test_init.py +320 -0
- assertion_cli-0.5.5/templates/ACTIVATION.md +0 -50
- assertion_cli-0.5.5/tests/test_init.py +0 -186
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/api.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/bundle.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/link.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/models.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/session.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/setup.cfg +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/templates/__init__.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_api.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_git.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_link.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_main.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_new.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.5 → assertion_cli-0.5.7}/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.7
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -55,11 +55,10 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
55
55
|
|
|
56
56
|
That's it. The `Publish CLI to PyPI` workflow auto-triggers on pushes that
|
|
57
57
|
change `cli/pyproject.toml`. It reads the version from the file, runs tests,
|
|
58
|
-
builds, publishes to PyPI, pushes a `cli-v<version>` tag
|
|
59
|
-
GitHub Release with auto-generated notes.
|
|
58
|
+
builds, publishes to PyPI, and pushes a `cli-v<version>` tag.
|
|
60
59
|
|
|
61
60
|
If the version hasn't changed (e.g. you updated a dependency), the workflow
|
|
62
|
-
skips publishing — only version bumps trigger a
|
|
61
|
+
skips publishing — only version bumps trigger a publish.
|
|
63
62
|
|
|
64
63
|
You can also trigger the workflow manually from the GitHub Actions UI — it
|
|
65
64
|
reads the version from the current `pyproject.toml` on `main`.
|
|
@@ -41,11 +41,10 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
41
41
|
|
|
42
42
|
That's it. The `Publish CLI to PyPI` workflow auto-triggers on pushes that
|
|
43
43
|
change `cli/pyproject.toml`. It reads the version from the file, runs tests,
|
|
44
|
-
builds, publishes to PyPI, pushes a `cli-v<version>` tag
|
|
45
|
-
GitHub Release with auto-generated notes.
|
|
44
|
+
builds, publishes to PyPI, and pushes a `cli-v<version>` tag.
|
|
46
45
|
|
|
47
46
|
If the version hasn't changed (e.g. you updated a dependency), the workflow
|
|
48
|
-
skips publishing — only version bumps trigger a
|
|
47
|
+
skips publishing — only version bumps trigger a publish.
|
|
49
48
|
|
|
50
49
|
You can also trigger the workflow manually from the GitHub Actions UI — it
|
|
51
50
|
reads the version from the current `pyproject.toml` on `main`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: assertion-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.7
|
|
4
4
|
Summary: CLI for the Assertion API
|
|
5
5
|
Requires-Python: >=3.13
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -55,11 +55,10 @@ asrt verify-status # one-shot poll; loop with your own sleep
|
|
|
55
55
|
|
|
56
56
|
That's it. The `Publish CLI to PyPI` workflow auto-triggers on pushes that
|
|
57
57
|
change `cli/pyproject.toml`. It reads the version from the file, runs tests,
|
|
58
|
-
builds, publishes to PyPI, pushes a `cli-v<version>` tag
|
|
59
|
-
GitHub Release with auto-generated notes.
|
|
58
|
+
builds, publishes to PyPI, and pushes a `cli-v<version>` tag.
|
|
60
59
|
|
|
61
60
|
If the version hasn't changed (e.g. you updated a dependency), the workflow
|
|
62
|
-
skips publishing — only version bumps trigger a
|
|
61
|
+
skips publishing — only version bumps trigger a publish.
|
|
63
62
|
|
|
64
63
|
You can also trigger the workflow manually from the GitHub Actions UI — it
|
|
65
64
|
reads the version from the current `pyproject.toml` on `main`.
|
|
@@ -80,8 +80,8 @@ def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
|
80
80
|
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
81
81
|
# generated/refreshed by `asrt init` and `asrt checkpoint` and have nothing to
|
|
82
82
|
# do with the user's feature work. CLAUDE.md / AGENTS.md are intentionally
|
|
83
|
-
# NOT in this list:
|
|
84
|
-
#
|
|
83
|
+
# NOT in this list: the CLI never writes them, so any edits there are the
|
|
84
|
+
# user's own and should flow through to reviewers.
|
|
85
85
|
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
86
86
|
":(exclude).assertion",
|
|
87
87
|
":(exclude).claude/skills/assertion-cli",
|
|
@@ -2,7 +2,6 @@ import base64
|
|
|
2
2
|
import importlib.metadata
|
|
3
3
|
import importlib.resources
|
|
4
4
|
import json
|
|
5
|
-
import re
|
|
6
5
|
import shutil
|
|
7
6
|
from datetime import datetime, timezone
|
|
8
7
|
from pathlib import Path
|
|
@@ -101,10 +100,6 @@ def stacks() -> None:
|
|
|
101
100
|
typer.echo(render_stack_list(stack_list))
|
|
102
101
|
|
|
103
102
|
|
|
104
|
-
ASSERTION_MARKER_BEGIN = "<!-- assertion-cli:begin -->"
|
|
105
|
-
ASSERTION_MARKER_END = "<!-- assertion-cli:end -->"
|
|
106
|
-
|
|
107
|
-
|
|
108
103
|
def _load_template(name: str) -> str:
|
|
109
104
|
return (
|
|
110
105
|
importlib.resources.files("assertion_cli_templates")
|
|
@@ -125,45 +120,6 @@ def _write_full_file(path: Path, content: str, repo_root: Path) -> None:
|
|
|
125
120
|
typer.echo(f"Wrote {path.relative_to(repo_root)}")
|
|
126
121
|
|
|
127
122
|
|
|
128
|
-
def _apply_marked_block(path: Path, body: str, repo_root: Path) -> None:
|
|
129
|
-
"""Idempotently insert/update an assertion-marked block in a markdown file.
|
|
130
|
-
|
|
131
|
-
Existing content outside the markers is preserved exactly.
|
|
132
|
-
Silent on no-op; echoes only when a write actually happens.
|
|
133
|
-
"""
|
|
134
|
-
marked_block = f"{ASSERTION_MARKER_BEGIN}\n{body}\n{ASSERTION_MARKER_END}\n"
|
|
135
|
-
pattern = re.compile(
|
|
136
|
-
re.escape(ASSERTION_MARKER_BEGIN)
|
|
137
|
-
+ r".*?"
|
|
138
|
-
+ re.escape(ASSERTION_MARKER_END)
|
|
139
|
-
+ r"\n?",
|
|
140
|
-
re.DOTALL,
|
|
141
|
-
)
|
|
142
|
-
|
|
143
|
-
if path.exists():
|
|
144
|
-
current = path.read_text(encoding="utf-8")
|
|
145
|
-
if pattern.search(current):
|
|
146
|
-
# Replace via a callable so re.sub does NOT interpret backslash
|
|
147
|
-
# escapes (e.g. `\n`, `\g`) in the block — the activation template
|
|
148
|
-
# contains literal shell snippets like `printf '%s\n\n...'`, and a
|
|
149
|
-
# string replacement would mangle them, breaking init idempotency.
|
|
150
|
-
new_content = pattern.sub(lambda _match: marked_block, current)
|
|
151
|
-
else:
|
|
152
|
-
sep = (
|
|
153
|
-
""
|
|
154
|
-
if current.endswith("\n\n")
|
|
155
|
-
else ("\n" if current.endswith("\n") else "\n\n")
|
|
156
|
-
)
|
|
157
|
-
new_content = current + sep + marked_block
|
|
158
|
-
if new_content != current:
|
|
159
|
-
path.write_text(new_content, encoding="utf-8")
|
|
160
|
-
typer.echo(f"Updated {path.relative_to(repo_root)}")
|
|
161
|
-
else:
|
|
162
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
-
path.write_text(marked_block, encoding="utf-8")
|
|
164
|
-
typer.echo(f"Wrote {path.relative_to(repo_root)}")
|
|
165
|
-
|
|
166
|
-
|
|
167
123
|
def _refresh_skill_files(repo_root: Path) -> None:
|
|
168
124
|
"""Write/refresh the assertion-owned skill files for each coding agent.
|
|
169
125
|
|
|
@@ -186,55 +142,55 @@ def _refresh_skill_files(repo_root: Path) -> None:
|
|
|
186
142
|
)
|
|
187
143
|
|
|
188
144
|
|
|
189
|
-
|
|
145
|
+
_ASSERTION_GITIGNORE_RULES = (".assertion/*", "!.assertion/seed.py")
|
|
190
146
|
|
|
191
147
|
|
|
192
|
-
def
|
|
193
|
-
"""
|
|
194
|
-
|
|
195
|
-
`.assertion/` holds per-session state (prompts, checkpoint message,
|
|
196
|
-
metadata, screenshots) — purely local working data. Without this entry it
|
|
197
|
-
surfaces as untracked changes that `asrt checkpoint`'s diff collector
|
|
198
|
-
would otherwise bundle and send to the reviewers.
|
|
199
|
-
"""
|
|
148
|
+
def _ensure_gitignore_limits_assertion_state(repo_root: Path) -> None:
|
|
149
|
+
"""Ignore local Assertion state while allowing committed seed hooks."""
|
|
200
150
|
gitignore = repo_root / ".gitignore"
|
|
201
151
|
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
152
|
+
lines = existing.splitlines()
|
|
153
|
+
stripped_lines = {line.strip() for line in lines}
|
|
154
|
+
|
|
155
|
+
missing_rules = [
|
|
156
|
+
rule for rule in _ASSERTION_GITIGNORE_RULES if rule not in stripped_lines
|
|
157
|
+
]
|
|
158
|
+
if not missing_rules:
|
|
205
159
|
return
|
|
206
|
-
prefix = "" if not existing or existing.endswith("\n") else "\n"
|
|
207
|
-
gitignore.write_text(
|
|
208
|
-
existing + prefix + _GITIGNORE_ASSERTION_ENTRY + "\n", encoding="utf-8"
|
|
209
|
-
)
|
|
210
|
-
typer.echo(
|
|
211
|
-
f"Updated {gitignore.relative_to(repo_root)} (added {_GITIGNORE_ASSERTION_ENTRY})"
|
|
212
|
-
)
|
|
213
160
|
|
|
161
|
+
added_lines = []
|
|
162
|
+
if "# Assertion CLI local state" not in stripped_lines:
|
|
163
|
+
added_lines.append("# Assertion CLI local state")
|
|
164
|
+
added_lines.extend(missing_rules)
|
|
214
165
|
|
|
215
|
-
|
|
216
|
-
|
|
166
|
+
content = existing.rstrip()
|
|
167
|
+
if content:
|
|
168
|
+
content += "\n\n"
|
|
169
|
+
content += "\n".join(added_lines) + "\n"
|
|
217
170
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
activation_body = _load_template("ACTIVATION.md").strip()
|
|
224
|
-
_apply_marked_block(repo_root / "CLAUDE.md", activation_body, repo_root)
|
|
225
|
-
_apply_marked_block(repo_root / "AGENTS.md", activation_body, repo_root)
|
|
171
|
+
gitignore.write_text(content, encoding="utf-8")
|
|
172
|
+
typer.echo(
|
|
173
|
+
f"Updated {gitignore.relative_to(repo_root)} "
|
|
174
|
+
"(ignored .assertion/* except .assertion/seed.py)"
|
|
175
|
+
)
|
|
226
176
|
|
|
227
177
|
|
|
228
178
|
@app.command("init")
|
|
229
179
|
def init() -> None:
|
|
230
180
|
"""One-time bootstrap: install the assertion-cli skill files into this repo.
|
|
231
181
|
|
|
232
|
-
Writes `.claude/skills/assertion-cli/SKILL.md
|
|
233
|
-
|
|
234
|
-
`uv tool install assertion-cli` so the coding agent (Claude Code, Codex, Cursor)
|
|
235
|
-
the skill and knows to call `asrt`.
|
|
236
|
-
|
|
237
|
-
|
|
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.
|
|
188
|
+
|
|
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).
|
|
238
194
|
|
|
239
195
|
Also records the current HEAD SHA as the diff base for all future checkpoint/verify
|
|
240
196
|
calls in this repo. The base is what the backend `git checkout`s against before
|
|
@@ -243,8 +199,7 @@ def init() -> None:
|
|
|
243
199
|
"""
|
|
244
200
|
repo_root = find_git_root(Path.cwd())
|
|
245
201
|
_refresh_skill_files(repo_root)
|
|
246
|
-
|
|
247
|
-
_ensure_gitignore_excludes_assertion(repo_root)
|
|
202
|
+
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
248
203
|
|
|
249
204
|
base_sha = get_head_sha(repo_root)
|
|
250
205
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
@@ -342,9 +297,9 @@ def checkpoint(
|
|
|
342
297
|
repo_root = find_git_root(Path.cwd())
|
|
343
298
|
|
|
344
299
|
# Refresh the assertion-owned skill files so the agent always loads the
|
|
345
|
-
# version that ships with the installed CLI.
|
|
346
|
-
# CLAUDE.md / AGENTS.md
|
|
347
|
-
#
|
|
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.
|
|
348
303
|
_refresh_skill_files(repo_root)
|
|
349
304
|
|
|
350
305
|
existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
|
|
@@ -16,7 +16,16 @@ Before any `asrt` call:
|
|
|
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
|
+
**First command sequence for a new work session:**
|
|
20
|
+
|
|
21
|
+
1. Before any `asrt` call, check whether this repo may have previous Assertion state in `.assertion/`.
|
|
22
|
+
2. If previous Assertion state exists, ask the user: "Do you want to clean up the previous Assertion session and start fresh?"
|
|
23
|
+
3. When the harness supports structured prompts or selectable options, present the question with selectable `Yes` and `No` options. Do not ask the user to type `yes` or `no` unless selectable options are unavailable.
|
|
24
|
+
4. If the user answers yes, run `asrt new` before any other `asrt` command.
|
|
25
|
+
5. If the user answers no, continue with the existing session state.
|
|
26
|
+
6. After the reset decision is resolved, immediately log the active user prompt with `asrt prompt`. Treat the cleanup confirmation itself as a control response, not user intent to record, when it only answers this reset question.
|
|
27
|
+
|
|
28
|
+
This check is the only allowed exception to Rule 1's "first thing" requirement: resolve it before the first `asrt prompt` call, because `asrt new` clears the prompts log. `asrt new` clears the previous session's in-flight state (metadata, prompts log, link, verification status, screenshots) and records the current `HEAD` as the new diff base, so a new session starts clean on the current branch and anchors to the stack you pick on the first checkpoint. Do not run `asrt new` without explicit user consent — it resets the session.
|
|
20
29
|
|
|
21
30
|
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 and `asrt new` to reset for a fresh session.
|
|
22
31
|
|
|
@@ -32,6 +41,8 @@ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha
|
|
|
32
41
|
asrt prompt "<exact text of the user's message>"
|
|
33
42
|
```
|
|
34
43
|
|
|
44
|
+
Exception: on the first user turn of a new work session, perform the Preflight fresh-session check first. If the user approves a reset, run `asrt new` before `asrt prompt`; otherwise skip the reset. Once that decision is resolved, immediately run `asrt prompt` for the active user message so the new/current session records the user's intent. If the only separate user message during preflight is a plain reset confirmation, do not record that confirmation as its own prompt.
|
|
45
|
+
|
|
35
46
|
This applies to **every user turn**, not just the first. It applies even when the message is a one-word direction change ("nope"), a clarification ("use Postgres, not SQLite"), or a follow-up after verify ("also add dark mode").
|
|
36
47
|
|
|
37
48
|
**Why:** The prompts log is the verifier's source of truth for what the user asked for and how their direction evolved. It is shipped with every checkpoint and the final verify, and persisted in the verification database. A missed turn causes the verifier to flag legitimate work as off-spec because it cannot see the intent change that authorized the work.
|
|
@@ -193,12 +204,15 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
|
|
|
193
204
|
|
|
194
205
|
**Do not open a PR without a clean `asrt verify` / `asrt verify-status`, and do not open one whose body omits the session marker.** Do not end a session with a failed/dirty verify unless the verification service itself is unavailable.
|
|
195
206
|
|
|
207
|
+
**After the PR marker is confirmed:** ask the user whether they want to clean up the completed Assertion session and start fresh for the next session. If the user says yes, run `asrt new`. If the user says no, leave the existing `.assertion/` state intact.
|
|
208
|
+
|
|
196
209
|
---
|
|
197
210
|
|
|
198
211
|
## Quick reference
|
|
199
212
|
|
|
200
213
|
```
|
|
201
214
|
asrt prompt "<message>" # every user turn, before anything else
|
|
215
|
+
asrt new # reset local run state only after user consent
|
|
202
216
|
asrt stacks # list every stack — read once before first checkpoint
|
|
203
217
|
asrt checkpoint --stack <id> "..." # first checkpoint of a session
|
|
204
218
|
asrt checkpoint "..." # subsequent checkpoints — auto-continues
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from typer.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_GIT_COMMIT_ENV = {
|
|
15
|
+
"GIT_AUTHOR_NAME": "T",
|
|
16
|
+
"GIT_AUTHOR_EMAIL": "t@t",
|
|
17
|
+
"GIT_COMMITTER_NAME": "T",
|
|
18
|
+
"GIT_COMMITTER_EMAIL": "t@t",
|
|
19
|
+
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _init_repo(tmp_path: Path) -> None:
|
|
24
|
+
"""Init a git repo with an initial commit so `git rev-parse HEAD` works.
|
|
25
|
+
|
|
26
|
+
`asrt init` records HEAD as the diff base, so tests need a resolvable HEAD.
|
|
27
|
+
"""
|
|
28
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
29
|
+
subprocess.run(
|
|
30
|
+
["git", "commit", "--allow-empty", "-m", "init"],
|
|
31
|
+
cwd=tmp_path,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
check=True,
|
|
34
|
+
env={**_GIT_COMMIT_ENV, "HOME": str(tmp_path)},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_init_writes_all_agent_destinations(tmp_path: Path, monkeypatch) -> None:
|
|
39
|
+
_init_repo(tmp_path)
|
|
40
|
+
monkeypatch.chdir(tmp_path)
|
|
41
|
+
|
|
42
|
+
result = runner.invoke(main.app, ["init"])
|
|
43
|
+
assert result.exit_code == 0, result.stderr
|
|
44
|
+
|
|
45
|
+
# Claude Code: skill registry
|
|
46
|
+
claude_skill = tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md"
|
|
47
|
+
assert claude_skill.exists()
|
|
48
|
+
assert "name: assertion-cli" in claude_skill.read_text()
|
|
49
|
+
|
|
50
|
+
# Codex + Cursor: shared skill registry, byte-identical
|
|
51
|
+
agents_skill = tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md"
|
|
52
|
+
assert agents_skill.exists()
|
|
53
|
+
assert agents_skill.read_text() == claude_skill.read_text()
|
|
54
|
+
|
|
55
|
+
# init must never create or touch user-owned CLAUDE.md / AGENTS.md — the
|
|
56
|
+
# skill files are auto-discovered, and editing user docs would leak the
|
|
57
|
+
# assertion bootstrap into the verification diff.
|
|
58
|
+
assert not (tmp_path / "CLAUDE.md").exists()
|
|
59
|
+
assert not (tmp_path / "AGENTS.md").exists()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_init_ignores_assertion_state_but_keeps_seed_trackable(
|
|
63
|
+
tmp_path: Path, monkeypatch
|
|
64
|
+
) -> None:
|
|
65
|
+
_init_repo(tmp_path)
|
|
66
|
+
monkeypatch.chdir(tmp_path)
|
|
67
|
+
|
|
68
|
+
existing_gitignore = "*.pyc\n"
|
|
69
|
+
(tmp_path / ".gitignore").write_text(existing_gitignore)
|
|
70
|
+
(tmp_path / ".assertion").mkdir()
|
|
71
|
+
(tmp_path / ".assertion" / "base_sha").write_text("local state\n")
|
|
72
|
+
(tmp_path / ".assertion" / "seed.py").write_text("from assertion_seed import seed\n")
|
|
73
|
+
|
|
74
|
+
result = runner.invoke(main.app, ["init"])
|
|
75
|
+
assert result.exit_code == 0, result.stderr
|
|
76
|
+
|
|
77
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
78
|
+
"*.pyc\n\n"
|
|
79
|
+
"# Assertion CLI local state\n"
|
|
80
|
+
".assertion/*\n"
|
|
81
|
+
"!.assertion/seed.py\n"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
ignored_state = subprocess.run(
|
|
85
|
+
["git", "check-ignore", ".assertion/base_sha"],
|
|
86
|
+
cwd=tmp_path,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
)
|
|
89
|
+
trackable_seed = subprocess.run(
|
|
90
|
+
["git", "check-ignore", ".assertion/seed.py"],
|
|
91
|
+
cwd=tmp_path,
|
|
92
|
+
capture_output=True,
|
|
93
|
+
)
|
|
94
|
+
assert ignored_state.returncode == 0
|
|
95
|
+
assert trackable_seed.returncode == 1
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_init_creates_gitignore_for_assertion_state(tmp_path: Path, monkeypatch) -> None:
|
|
99
|
+
_init_repo(tmp_path)
|
|
100
|
+
monkeypatch.chdir(tmp_path)
|
|
101
|
+
|
|
102
|
+
result = runner.invoke(main.app, ["init"])
|
|
103
|
+
assert result.exit_code == 0, result.stderr
|
|
104
|
+
|
|
105
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
106
|
+
"# Assertion CLI local state\n"
|
|
107
|
+
".assertion/*\n"
|
|
108
|
+
"!.assertion/seed.py\n"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_init_preserves_broad_assertion_gitignore_entry_while_adding_missing_rules(
|
|
113
|
+
tmp_path: Path, monkeypatch
|
|
114
|
+
) -> None:
|
|
115
|
+
_init_repo(tmp_path)
|
|
116
|
+
monkeypatch.chdir(tmp_path)
|
|
117
|
+
(tmp_path / ".gitignore").write_text(".assertion/\n")
|
|
118
|
+
|
|
119
|
+
result = runner.invoke(main.app, ["init"])
|
|
120
|
+
assert result.exit_code == 0, result.stderr
|
|
121
|
+
|
|
122
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
123
|
+
".assertion/\n\n"
|
|
124
|
+
"# Assertion CLI local state\n"
|
|
125
|
+
".assertion/*\n"
|
|
126
|
+
"!.assertion/seed.py\n"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_init_adds_only_missing_assertion_gitignore_rules(
|
|
131
|
+
tmp_path: Path, monkeypatch
|
|
132
|
+
) -> None:
|
|
133
|
+
_init_repo(tmp_path)
|
|
134
|
+
monkeypatch.chdir(tmp_path)
|
|
135
|
+
(tmp_path / ".gitignore").write_text(
|
|
136
|
+
"# Assertion CLI local state\n"
|
|
137
|
+
".assertion/*\n"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
result = runner.invoke(main.app, ["init"])
|
|
141
|
+
assert result.exit_code == 0, result.stderr
|
|
142
|
+
|
|
143
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
144
|
+
"# Assertion CLI local state\n"
|
|
145
|
+
".assertion/*\n\n"
|
|
146
|
+
"!.assertion/seed.py\n"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_init_leaves_gitignore_unchanged_when_assertion_rules_exist(
|
|
151
|
+
tmp_path: Path, monkeypatch
|
|
152
|
+
) -> None:
|
|
153
|
+
_init_repo(tmp_path)
|
|
154
|
+
monkeypatch.chdir(tmp_path)
|
|
155
|
+
existing_gitignore = (
|
|
156
|
+
"*.pyc\n\n"
|
|
157
|
+
"# Assertion CLI local state\n"
|
|
158
|
+
".assertion/*\n"
|
|
159
|
+
"!.assertion/seed.py\n"
|
|
160
|
+
)
|
|
161
|
+
(tmp_path / ".gitignore").write_text(existing_gitignore)
|
|
162
|
+
|
|
163
|
+
result = runner.invoke(main.app, ["init"])
|
|
164
|
+
assert result.exit_code == 0, result.stderr
|
|
165
|
+
|
|
166
|
+
assert (tmp_path / ".gitignore").read_text() == existing_gitignore
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_init_skill_prompts_before_new_session_cleanup(
|
|
170
|
+
tmp_path: Path, monkeypatch
|
|
171
|
+
) -> None:
|
|
172
|
+
_init_repo(tmp_path)
|
|
173
|
+
monkeypatch.chdir(tmp_path)
|
|
174
|
+
|
|
175
|
+
result = runner.invoke(main.app, ["init"])
|
|
176
|
+
assert result.exit_code == 0, result.stderr
|
|
177
|
+
|
|
178
|
+
skill = (
|
|
179
|
+
tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md"
|
|
180
|
+
).read_text()
|
|
181
|
+
assert "First command sequence for a new work session" in skill
|
|
182
|
+
assert (
|
|
183
|
+
'ask the user: "Do you want to clean up the previous Assertion session '
|
|
184
|
+
'and start fresh?"'
|
|
185
|
+
) in skill
|
|
186
|
+
assert (
|
|
187
|
+
"present the question with selectable `Yes` and `No` options"
|
|
188
|
+
) in skill
|
|
189
|
+
assert (
|
|
190
|
+
"Do not ask the user to type `yes` or `no` unless selectable options "
|
|
191
|
+
"are unavailable"
|
|
192
|
+
) in skill
|
|
193
|
+
assert (
|
|
194
|
+
"resolve it before the first `asrt prompt` call, because `asrt new` clears "
|
|
195
|
+
"the prompts log"
|
|
196
|
+
) in skill
|
|
197
|
+
assert (
|
|
198
|
+
"If the user answers yes, run `asrt new` before any other `asrt` command"
|
|
199
|
+
) in skill
|
|
200
|
+
assert (
|
|
201
|
+
"After the reset decision is resolved, immediately log the active user prompt "
|
|
202
|
+
"with `asrt prompt`"
|
|
203
|
+
) in skill
|
|
204
|
+
assert (
|
|
205
|
+
"Treat the cleanup confirmation itself as a control response, not user intent "
|
|
206
|
+
"to record"
|
|
207
|
+
) in skill
|
|
208
|
+
assert (
|
|
209
|
+
"If the only separate user message during preflight is a plain reset "
|
|
210
|
+
"confirmation, do not record that confirmation as its own prompt"
|
|
211
|
+
) in skill
|
|
212
|
+
assert "Do not run `asrt new` without explicit user consent" in skill
|
|
213
|
+
assert "After the PR marker is confirmed" in skill
|
|
214
|
+
assert "clean up the completed Assertion session" in skill
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_init_is_idempotent(tmp_path: Path, monkeypatch) -> None:
|
|
218
|
+
_init_repo(tmp_path)
|
|
219
|
+
monkeypatch.chdir(tmp_path)
|
|
220
|
+
|
|
221
|
+
runner.invoke(main.app, ["init"])
|
|
222
|
+
snapshots = {
|
|
223
|
+
p: (tmp_path / p).read_text()
|
|
224
|
+
for p in [
|
|
225
|
+
".claude/skills/assertion-cli/SKILL.md",
|
|
226
|
+
".agents/skills/assertion-cli/SKILL.md",
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
runner.invoke(main.app, ["init"])
|
|
231
|
+
for rel, before in snapshots.items():
|
|
232
|
+
assert (tmp_path / rel).read_text() == before, rel
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_init_leaves_existing_activation_files_untouched(
|
|
236
|
+
tmp_path: Path, monkeypatch
|
|
237
|
+
) -> None:
|
|
238
|
+
"""init must not modify user-owned CLAUDE.md / AGENTS.md, byte for byte.
|
|
239
|
+
|
|
240
|
+
Previously init patched a marked activation block into these files. Because
|
|
241
|
+
the base SHA is recorded at init time, that edit then surfaced in every
|
|
242
|
+
checkpoint/verify diff as unrelated bootstrap noise. The skill files are
|
|
243
|
+
auto-discovered, so the activation block is unnecessary.
|
|
244
|
+
"""
|
|
245
|
+
_init_repo(tmp_path)
|
|
246
|
+
monkeypatch.chdir(tmp_path)
|
|
247
|
+
|
|
248
|
+
existing_claude = "# My existing CLAUDE.md\n\nSome custom instructions.\n"
|
|
249
|
+
existing_agents = "# Project guidelines\n\nLint with ruff.\n"
|
|
250
|
+
(tmp_path / "CLAUDE.md").write_text(existing_claude)
|
|
251
|
+
(tmp_path / "AGENTS.md").write_text(existing_agents)
|
|
252
|
+
|
|
253
|
+
result = runner.invoke(main.app, ["init"])
|
|
254
|
+
assert result.exit_code == 0
|
|
255
|
+
|
|
256
|
+
assert (tmp_path / "CLAUDE.md").read_text() == existing_claude
|
|
257
|
+
assert (tmp_path / "AGENTS.md").read_text() == existing_agents
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_init_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
261
|
+
monkeypatch.chdir(tmp_path)
|
|
262
|
+
|
|
263
|
+
result = runner.invoke(main.app, ["init"])
|
|
264
|
+
assert result.exit_code == 1
|
|
265
|
+
assert "git repository" in result.stderr
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_init_records_head_sha_as_base(tmp_path: Path, monkeypatch) -> None:
|
|
269
|
+
"""`asrt init` pins the diff base for this repo to current HEAD.
|
|
270
|
+
|
|
271
|
+
Every subsequent checkpoint/verify diffs against this SHA, so backend
|
|
272
|
+
`git checkout` always targets a commit the user/agent has committed at
|
|
273
|
+
least once locally (and presumably pushed).
|
|
274
|
+
"""
|
|
275
|
+
_init_repo(tmp_path)
|
|
276
|
+
monkeypatch.chdir(tmp_path)
|
|
277
|
+
|
|
278
|
+
result = runner.invoke(main.app, ["init"])
|
|
279
|
+
assert result.exit_code == 0, result.stderr
|
|
280
|
+
|
|
281
|
+
head_sha = subprocess.run(
|
|
282
|
+
["git", "rev-parse", "HEAD"],
|
|
283
|
+
cwd=tmp_path,
|
|
284
|
+
capture_output=True,
|
|
285
|
+
text=True,
|
|
286
|
+
check=True,
|
|
287
|
+
).stdout.strip()
|
|
288
|
+
|
|
289
|
+
base_sha_path = tmp_path / ".assertion" / "base_sha"
|
|
290
|
+
assert base_sha_path.exists()
|
|
291
|
+
assert base_sha_path.read_text().strip() == head_sha
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_init_errors_when_repo_has_no_commits(tmp_path: Path, monkeypatch) -> None:
|
|
295
|
+
"""No HEAD → no base SHA to record → init should fail cleanly."""
|
|
296
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
297
|
+
monkeypatch.chdir(tmp_path)
|
|
298
|
+
|
|
299
|
+
result = runner.invoke(main.app, ["init"])
|
|
300
|
+
assert result.exit_code == 1
|
|
301
|
+
assert not (tmp_path / ".assertion" / "base_sha").exists()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_refresh_skill_files_does_not_touch_activation_files(tmp_path: Path) -> None:
|
|
305
|
+
"""`asrt checkpoint` must refresh skill files without dirtying CLAUDE.md / AGENTS.md.
|
|
306
|
+
|
|
307
|
+
User-authored CLAUDE.md may be tracked in git; silently editing it on every
|
|
308
|
+
checkpoint is hostile. Only `asrt init` is allowed to touch activation blocks.
|
|
309
|
+
"""
|
|
310
|
+
user_claude = "# My CLAUDE.md\n\nCustom rules.\n"
|
|
311
|
+
user_agents = "# Project guidelines\n"
|
|
312
|
+
(tmp_path / "CLAUDE.md").write_text(user_claude)
|
|
313
|
+
(tmp_path / "AGENTS.md").write_text(user_agents)
|
|
314
|
+
|
|
315
|
+
main._refresh_skill_files(tmp_path)
|
|
316
|
+
|
|
317
|
+
assert (tmp_path / "CLAUDE.md").read_text() == user_claude
|
|
318
|
+
assert (tmp_path / "AGENTS.md").read_text() == user_agents
|
|
319
|
+
assert (tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
320
|
+
assert (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
@@ -1,50 +0,0 @@
|
|
|
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. **Log every user message:** run `asrt prompt "<exact text>"` before anything else, on every turn — it's the verifier's record of intent.
|
|
11
|
-
2. **Checkpoint at trajectory-feedback moments**, not every change. First checkpoint: `asrt stacks`, pick the stack that fits, then `asrt checkpoint --stack <id> "<summary>"`; later ones drop `--stack` and auto-continue. A feature build is a handful of checkpoints.
|
|
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 — and that PR **must** carry the hidden Assertion session marker (see below).
|
|
13
|
-
|
|
14
|
-
---
|
|
15
|
-
|
|
16
|
-
## ⚠️ REQUIRED: every PR must embed the hidden session marker
|
|
17
|
-
|
|
18
|
-
**This is the single most-missed step, and it is not optional.** A PR opened from an Assertion session that omits the marker cannot be linked back to its verification, so the work reads as unverified.
|
|
19
|
-
|
|
20
|
-
The Assertion GitHub bot links a PR to its verification session by **branch name first**. When the branch name doesn't match the one you verified on (you renamed it, the head ref differs, etc.), the bot falls back to reading the marker. No marker → no fallback → no link.
|
|
21
|
-
|
|
22
|
-
`asrt get-link` prints the marker as an HTML comment that GitHub hides from the rendered description but still delivers to the bot in the webhook payload:
|
|
23
|
-
|
|
24
|
-
```
|
|
25
|
-
<!-- assertion-session: https://app.tryassertion.com/sessions/<id> -->
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
**Open the PR with the marker embedded** — never paste the bare URL into the visible body; use the command so the format stays exact:
|
|
29
|
-
|
|
30
|
-
```
|
|
31
|
-
gh pr create --title "<title>" --body "$(printf '%s\n\n%s\n' '<one-paragraph why>' "$(asrt get-link)")"
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
If a PR already exists for the branch, add the marker instead of opening a new one:
|
|
35
|
-
|
|
36
|
-
```
|
|
37
|
-
gh pr edit --body "$(printf '%s\n\n%s\n' '<existing summary>' "$(asrt get-link)")"
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
**Then confirm it landed.** The marker won't show in the rendered description — that's expected — so verify it's in the raw body:
|
|
41
|
-
|
|
42
|
-
```
|
|
43
|
-
gh pr view --json body --jq .body | grep -q 'assertion-session:' && echo "marker present" || echo "MARKER MISSING — fix before continuing"
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
---
|
|
47
|
-
|
|
48
|
-
## Hard gates (do not bypass)
|
|
49
|
-
- **No PR whose body omits the session marker.** If `asrt get-link` has nothing to return, you haven't verified yet — go back to rule 3.
|
|
50
|
-
- **Confirm the marker after creating/editing the PR** with the `gh pr view` check above. Treat a missing marker as a blocking error, not a warning.
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import subprocess
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
from typer.testing import CliRunner
|
|
7
|
-
|
|
8
|
-
import main
|
|
9
|
-
from main import ASSERTION_MARKER_BEGIN, ASSERTION_MARKER_END
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
runner = CliRunner()
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
_GIT_COMMIT_ENV = {
|
|
16
|
-
"GIT_AUTHOR_NAME": "T",
|
|
17
|
-
"GIT_AUTHOR_EMAIL": "t@t",
|
|
18
|
-
"GIT_COMMITTER_NAME": "T",
|
|
19
|
-
"GIT_COMMITTER_EMAIL": "t@t",
|
|
20
|
-
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def _init_repo(tmp_path: Path) -> None:
|
|
25
|
-
"""Init a git repo with an initial commit so `git rev-parse HEAD` works.
|
|
26
|
-
|
|
27
|
-
`asrt init` records HEAD as the diff base, so tests need a resolvable HEAD.
|
|
28
|
-
"""
|
|
29
|
-
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
30
|
-
subprocess.run(
|
|
31
|
-
["git", "commit", "--allow-empty", "-m", "init"],
|
|
32
|
-
cwd=tmp_path,
|
|
33
|
-
capture_output=True,
|
|
34
|
-
check=True,
|
|
35
|
-
env={**_GIT_COMMIT_ENV, "HOME": str(tmp_path)},
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def test_init_writes_all_agent_destinations(tmp_path: Path, monkeypatch) -> None:
|
|
40
|
-
_init_repo(tmp_path)
|
|
41
|
-
monkeypatch.chdir(tmp_path)
|
|
42
|
-
|
|
43
|
-
result = runner.invoke(main.app, ["init"])
|
|
44
|
-
assert result.exit_code == 0, result.stderr
|
|
45
|
-
|
|
46
|
-
# Claude Code: skill registry
|
|
47
|
-
claude_skill = tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md"
|
|
48
|
-
assert claude_skill.exists()
|
|
49
|
-
assert "name: assertion-cli" in claude_skill.read_text()
|
|
50
|
-
|
|
51
|
-
# Codex + Cursor: shared skill registry, byte-identical
|
|
52
|
-
agents_skill = tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md"
|
|
53
|
-
assert agents_skill.exists()
|
|
54
|
-
assert agents_skill.read_text() == claude_skill.read_text()
|
|
55
|
-
|
|
56
|
-
# CLAUDE.md + AGENTS.md activation marker blocks
|
|
57
|
-
for filename in ["CLAUDE.md", "AGENTS.md"]:
|
|
58
|
-
body = (tmp_path / filename).read_text()
|
|
59
|
-
assert ASSERTION_MARKER_BEGIN in body
|
|
60
|
-
assert ASSERTION_MARKER_END in body
|
|
61
|
-
assert "Assertion verification" in body
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def test_init_is_idempotent(tmp_path: Path, monkeypatch) -> None:
|
|
65
|
-
_init_repo(tmp_path)
|
|
66
|
-
monkeypatch.chdir(tmp_path)
|
|
67
|
-
|
|
68
|
-
runner.invoke(main.app, ["init"])
|
|
69
|
-
snapshots = {
|
|
70
|
-
p: (tmp_path / p).read_text()
|
|
71
|
-
for p in [
|
|
72
|
-
".claude/skills/assertion-cli/SKILL.md",
|
|
73
|
-
".agents/skills/assertion-cli/SKILL.md",
|
|
74
|
-
"CLAUDE.md",
|
|
75
|
-
"AGENTS.md",
|
|
76
|
-
]
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
runner.invoke(main.app, ["init"])
|
|
80
|
-
for rel, before in snapshots.items():
|
|
81
|
-
assert (tmp_path / rel).read_text() == before, rel
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def test_init_appends_to_existing_unmarked_files(tmp_path: Path, monkeypatch) -> None:
|
|
85
|
-
_init_repo(tmp_path)
|
|
86
|
-
monkeypatch.chdir(tmp_path)
|
|
87
|
-
|
|
88
|
-
existing_claude = "# My existing CLAUDE.md\n\nSome custom instructions.\n"
|
|
89
|
-
existing_agents = "# Project guidelines\n\nLint with ruff.\n"
|
|
90
|
-
(tmp_path / "CLAUDE.md").write_text(existing_claude)
|
|
91
|
-
(tmp_path / "AGENTS.md").write_text(existing_agents)
|
|
92
|
-
|
|
93
|
-
result = runner.invoke(main.app, ["init"])
|
|
94
|
-
assert result.exit_code == 0
|
|
95
|
-
|
|
96
|
-
claude_body = (tmp_path / "CLAUDE.md").read_text()
|
|
97
|
-
agents_body = (tmp_path / "AGENTS.md").read_text()
|
|
98
|
-
assert claude_body.startswith(existing_claude)
|
|
99
|
-
assert agents_body.startswith(existing_agents)
|
|
100
|
-
assert ASSERTION_MARKER_BEGIN in claude_body
|
|
101
|
-
assert ASSERTION_MARKER_BEGIN in agents_body
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def test_init_replaces_stale_marked_section(tmp_path: Path, monkeypatch) -> None:
|
|
105
|
-
_init_repo(tmp_path)
|
|
106
|
-
monkeypatch.chdir(tmp_path)
|
|
107
|
-
|
|
108
|
-
stale = (
|
|
109
|
-
"# Existing\n\n"
|
|
110
|
-
f"{ASSERTION_MARKER_BEGIN}\n"
|
|
111
|
-
"outdated content\n"
|
|
112
|
-
f"{ASSERTION_MARKER_END}\n\n"
|
|
113
|
-
"Custom instructions after.\n"
|
|
114
|
-
)
|
|
115
|
-
(tmp_path / "CLAUDE.md").write_text(stale)
|
|
116
|
-
|
|
117
|
-
runner.invoke(main.app, ["init"])
|
|
118
|
-
|
|
119
|
-
body = (tmp_path / "CLAUDE.md").read_text()
|
|
120
|
-
assert "outdated content" not in body
|
|
121
|
-
assert "# Existing" in body
|
|
122
|
-
assert "Custom instructions after." in body
|
|
123
|
-
assert "Assertion verification" in body
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def test_init_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
127
|
-
monkeypatch.chdir(tmp_path)
|
|
128
|
-
|
|
129
|
-
result = runner.invoke(main.app, ["init"])
|
|
130
|
-
assert result.exit_code == 1
|
|
131
|
-
assert "git repository" in result.stderr
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def test_init_records_head_sha_as_base(tmp_path: Path, monkeypatch) -> None:
|
|
135
|
-
"""`asrt init` pins the diff base for this repo to current HEAD.
|
|
136
|
-
|
|
137
|
-
Every subsequent checkpoint/verify diffs against this SHA, so backend
|
|
138
|
-
`git checkout` always targets a commit the user/agent has committed at
|
|
139
|
-
least once locally (and presumably pushed).
|
|
140
|
-
"""
|
|
141
|
-
_init_repo(tmp_path)
|
|
142
|
-
monkeypatch.chdir(tmp_path)
|
|
143
|
-
|
|
144
|
-
result = runner.invoke(main.app, ["init"])
|
|
145
|
-
assert result.exit_code == 0, result.stderr
|
|
146
|
-
|
|
147
|
-
head_sha = subprocess.run(
|
|
148
|
-
["git", "rev-parse", "HEAD"],
|
|
149
|
-
cwd=tmp_path,
|
|
150
|
-
capture_output=True,
|
|
151
|
-
text=True,
|
|
152
|
-
check=True,
|
|
153
|
-
).stdout.strip()
|
|
154
|
-
|
|
155
|
-
base_sha_path = tmp_path / ".assertion" / "base_sha"
|
|
156
|
-
assert base_sha_path.exists()
|
|
157
|
-
assert base_sha_path.read_text().strip() == head_sha
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def test_init_errors_when_repo_has_no_commits(tmp_path: Path, monkeypatch) -> None:
|
|
161
|
-
"""No HEAD → no base SHA to record → init should fail cleanly."""
|
|
162
|
-
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
163
|
-
monkeypatch.chdir(tmp_path)
|
|
164
|
-
|
|
165
|
-
result = runner.invoke(main.app, ["init"])
|
|
166
|
-
assert result.exit_code == 1
|
|
167
|
-
assert not (tmp_path / ".assertion" / "base_sha").exists()
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
def test_refresh_skill_files_does_not_touch_activation_files(tmp_path: Path) -> None:
|
|
171
|
-
"""`asrt checkpoint` must refresh skill files without dirtying CLAUDE.md / AGENTS.md.
|
|
172
|
-
|
|
173
|
-
User-authored CLAUDE.md may be tracked in git; silently editing it on every
|
|
174
|
-
checkpoint is hostile. Only `asrt init` is allowed to touch activation blocks.
|
|
175
|
-
"""
|
|
176
|
-
user_claude = "# My CLAUDE.md\n\nCustom rules.\n"
|
|
177
|
-
user_agents = "# Project guidelines\n"
|
|
178
|
-
(tmp_path / "CLAUDE.md").write_text(user_claude)
|
|
179
|
-
(tmp_path / "AGENTS.md").write_text(user_agents)
|
|
180
|
-
|
|
181
|
-
main._refresh_skill_files(tmp_path)
|
|
182
|
-
|
|
183
|
-
assert (tmp_path / "CLAUDE.md").read_text() == user_claude
|
|
184
|
-
assert (tmp_path / "AGENTS.md").read_text() == user_agents
|
|
185
|
-
assert (tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
186
|
-
assert (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|