assertion-cli 0.5.7__tar.gz → 0.5.8__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.8}/PKG-INFO +3 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/README.md +2 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/PKG-INFO +3 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/SOURCES.txt +4 -2
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/git.py +23 -11
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/main.py +102 -33
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/models.py +1 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/pyproject.toml +1 -1
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/session.py +12 -11
- assertion_cli-0.5.8/templates/AGENTS.md +14 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/templates/SKILL.md +3 -2
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_git.py +54 -0
- assertion_cli-0.5.8/tests/test_install.py +155 -0
- assertion_cli-0.5.8/tests/test_session_start.py +225 -0
- assertion_cli-0.5.7/tests/test_init.py +0 -320
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/api.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/bundle.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/link.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/setup.cfg +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/templates/__init__.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_api.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_bundle.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_link.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_main.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_new.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_prompt.py +0 -0
- {assertion_cli-0.5.7 → assertion_cli-0.5.8}/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.8
|
|
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.8
|
|
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
|
|
@@ -10,7 +10,9 @@ def exit_with_error(message: str) -> NoReturn:
|
|
|
10
10
|
raise typer.Exit(code=1)
|
|
11
11
|
|
|
12
12
|
|
|
13
|
-
def run_git_command(
|
|
13
|
+
def run_git_command(
|
|
14
|
+
repo_root: Path, args: Sequence[str], *, preserve_stdout: bool = False
|
|
15
|
+
) -> str:
|
|
14
16
|
completed = subprocess.run(
|
|
15
17
|
["git", *args],
|
|
16
18
|
cwd=repo_root,
|
|
@@ -21,7 +23,7 @@ def run_git_command(repo_root: Path, args: Sequence[str]) -> str:
|
|
|
21
23
|
if completed.returncode != 0:
|
|
22
24
|
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
23
25
|
raise RuntimeError(message)
|
|
24
|
-
return completed.stdout.strip()
|
|
26
|
+
return completed.stdout if preserve_stdout else completed.stdout.strip()
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def find_git_root(start_path: Path) -> Path:
|
|
@@ -73,15 +75,16 @@ def _build_untracked_diff(repo_root: Path, rel_path: str) -> str:
|
|
|
73
75
|
if completed.returncode not in (0, 1):
|
|
74
76
|
message = completed.stderr.strip() or completed.stdout.strip() or "git failed"
|
|
75
77
|
raise RuntimeError(message)
|
|
76
|
-
return completed.stdout
|
|
78
|
+
return completed.stdout
|
|
77
79
|
|
|
78
80
|
|
|
79
81
|
# Paths the assertion-cli owns end-to-end. Excluded from the diff bundle so
|
|
80
82
|
# reviewers don't flag our own bootstrap files as "unrelated changes" — they're
|
|
81
|
-
# generated
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
83
|
+
# generated by `asrt install` and have nothing to do with the user's feature
|
|
84
|
+
# work. CLAUDE.md / AGENTS.md are intentionally NOT in this list: `asrt install`
|
|
85
|
+
# writes a small pointer block into them once at onboarding (meant to be
|
|
86
|
+
# committed before work starts, so it never shows in a work-session diff), but
|
|
87
|
+
# the rest of those files is the user's own and must flow through to reviewers.
|
|
85
88
|
_ASSERTION_EXCLUDED_PATHSPECS = [
|
|
86
89
|
":(exclude).assertion",
|
|
87
90
|
":(exclude).claude/skills/assertion-cli",
|
|
@@ -100,7 +103,9 @@ def get_uncommitted_diff(repo_root: Path, base_sha: str) -> str:
|
|
|
100
103
|
"""
|
|
101
104
|
try:
|
|
102
105
|
tracked = run_git_command(
|
|
103
|
-
repo_root,
|
|
106
|
+
repo_root,
|
|
107
|
+
["diff", base_sha, "--", *_ASSERTION_EXCLUDED_PATHSPECS],
|
|
108
|
+
preserve_stdout=True,
|
|
104
109
|
)
|
|
105
110
|
|
|
106
111
|
untracked_output = run_git_command(
|
|
@@ -119,8 +124,15 @@ def get_uncommitted_diff(repo_root: Path, base_sha: str) -> str:
|
|
|
119
124
|
if rel_path
|
|
120
125
|
]
|
|
121
126
|
|
|
122
|
-
parts = [
|
|
123
|
-
|
|
124
|
-
|
|
127
|
+
parts = [
|
|
128
|
+
_ensure_trailing_newline(p)
|
|
129
|
+
for p in [tracked, *untracked_diffs]
|
|
130
|
+
if p
|
|
131
|
+
]
|
|
132
|
+
return "".join(parts)
|
|
125
133
|
except RuntimeError as exc:
|
|
126
134
|
exit_with_error(f"Failed to collect git diff: {exc}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _ensure_trailing_newline(value: str) -> str:
|
|
138
|
+
return value if value.endswith("\n") else value + "\n"
|
|
@@ -121,10 +121,11 @@ def _write_full_file(path: Path, content: str, repo_root: Path) -> None:
|
|
|
121
121
|
|
|
122
122
|
|
|
123
123
|
def _refresh_skill_files(repo_root: Path) -> None:
|
|
124
|
-
"""
|
|
124
|
+
"""Overwrite the assertion-owned skill files with the bundled template.
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
Called only from `asrt install` (the one-time onboarding step), never on a
|
|
127
|
+
regular agent run — these files are entirely ours, so writing them to match
|
|
128
|
+
the installed CLI version cannot clobber user content.
|
|
128
129
|
|
|
129
130
|
`.claude/skills/` — Claude Code
|
|
130
131
|
`.agents/skills/` — Codex + Cursor (Cursor also accepts .cursor/skills/)
|
|
@@ -142,6 +143,55 @@ def _refresh_skill_files(repo_root: Path) -> None:
|
|
|
142
143
|
)
|
|
143
144
|
|
|
144
145
|
|
|
146
|
+
# Top-level agent instruction files. Unlike the skill files (which live in
|
|
147
|
+
# agent-owned `*/skills/` dirs), these are user-owned docs that may already hold
|
|
148
|
+
# the user's own content. So we inject an HTML-comment-delimited block rather
|
|
149
|
+
# than overwriting the whole file, and replace just that block on re-install.
|
|
150
|
+
_AGENT_DOC_FILES = ("AGENTS.md", "CLAUDE.md")
|
|
151
|
+
_AGENT_BLOCK_START = "<!-- assertion-cli:start -->"
|
|
152
|
+
_AGENT_BLOCK_END = "<!-- assertion-cli:end -->"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _agent_doc_block() -> str:
|
|
156
|
+
# No trailing newline: callers add exactly one when assembling the file so
|
|
157
|
+
# re-installs stay byte-idempotent (a block followed by the original
|
|
158
|
+
# post-marker text mustn't accumulate blank lines).
|
|
159
|
+
body = _load_template("AGENTS.md").strip("\n")
|
|
160
|
+
return f"{_AGENT_BLOCK_START}\n{body}\n{_AGENT_BLOCK_END}"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _upsert_agent_block(path: Path, block: str, repo_root: Path) -> None:
|
|
164
|
+
"""Insert/replace the Assertion block in a user-owned agent doc, idempotently.
|
|
165
|
+
|
|
166
|
+
Creates the file from the block if missing; replaces an existing block in
|
|
167
|
+
place; otherwise appends the block, leaving the user's content untouched.
|
|
168
|
+
"""
|
|
169
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
170
|
+
|
|
171
|
+
if _AGENT_BLOCK_START in existing and _AGENT_BLOCK_END in existing:
|
|
172
|
+
pre = existing.split(_AGENT_BLOCK_START, 1)[0]
|
|
173
|
+
post = existing.split(_AGENT_BLOCK_END, 1)[1]
|
|
174
|
+
prefix = pre.rstrip("\n") + "\n\n" if pre.strip() else ""
|
|
175
|
+
new_content = prefix + block + post
|
|
176
|
+
elif existing.strip():
|
|
177
|
+
new_content = existing.rstrip("\n") + "\n\n" + block + "\n"
|
|
178
|
+
else:
|
|
179
|
+
new_content = block + "\n"
|
|
180
|
+
|
|
181
|
+
if existing == new_content:
|
|
182
|
+
return
|
|
183
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
path.write_text(new_content, encoding="utf-8")
|
|
185
|
+
typer.echo(f"Wrote {path.relative_to(repo_root)}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _write_agent_docs(repo_root: Path) -> None:
|
|
189
|
+
"""Write the assertion-cli pointer into the top-level agent instruction files."""
|
|
190
|
+
block = _agent_doc_block()
|
|
191
|
+
for name in _AGENT_DOC_FILES:
|
|
192
|
+
_upsert_agent_block(repo_root / name, block, repo_root)
|
|
193
|
+
|
|
194
|
+
|
|
145
195
|
_ASSERTION_GITIGNORE_RULES = (".assertion/*", "!.assertion/seed.py")
|
|
146
196
|
|
|
147
197
|
|
|
@@ -175,30 +225,55 @@ def _ensure_gitignore_limits_assertion_state(repo_root: Path) -> None:
|
|
|
175
225
|
)
|
|
176
226
|
|
|
177
227
|
|
|
178
|
-
@app.command("
|
|
179
|
-
def
|
|
180
|
-
"""One-time
|
|
228
|
+
@app.command("install")
|
|
229
|
+
def install() -> None:
|
|
230
|
+
"""One-time onboarding: scaffold the Assertion skill + agent docs into this repo.
|
|
181
231
|
|
|
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.
|
|
232
|
+
Run this once after `uv tool install assertion-cli`. It writes:
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
234
|
+
- `.claude/skills/assertion-cli/SKILL.md` and
|
|
235
|
+
`.agents/skills/assertion-cli/SKILL.md` — the skill the coding agent
|
|
236
|
+
(Claude Code, Codex, Cursor) auto-discovers so it knows to call `asrt`.
|
|
237
|
+
- A short assertion-cli pointer block in the top-level `AGENTS.md` and
|
|
238
|
+
`CLAUDE.md`. These are user-owned, so the block is delimited by HTML
|
|
239
|
+
comments and re-installs replace just that block — any surrounding
|
|
240
|
+
content the user wrote is preserved.
|
|
241
|
+
- `.gitignore` rules that ignore local `.assertion/*` run state while
|
|
242
|
+
keeping committed seed hooks trackable.
|
|
194
243
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
rediscover it (which previously broke in sandboxes with no remote-tracking refs).
|
|
244
|
+
This is the onboarding step, not a per-run step: skill files are written
|
|
245
|
+
here, never on `asrt checkpoint`. After installing, run `asrt session start`
|
|
246
|
+
to record the diff base for a work session.
|
|
199
247
|
"""
|
|
200
248
|
repo_root = find_git_root(Path.cwd())
|
|
201
249
|
_refresh_skill_files(repo_root)
|
|
250
|
+
_write_agent_docs(repo_root)
|
|
251
|
+
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
252
|
+
|
|
253
|
+
typer.echo("")
|
|
254
|
+
typer.echo(
|
|
255
|
+
"Assertion skill installed. To start a work session, run `asrt session start`."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
session_app = typer.Typer(help="Manage Assertion work sessions.")
|
|
260
|
+
app.add_typer(session_app, name="session")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@session_app.command("start")
|
|
264
|
+
def session_start() -> None:
|
|
265
|
+
"""Record the current HEAD SHA as the diff base for this repo's work session.
|
|
266
|
+
|
|
267
|
+
The base is what the backend `git checkout`s against before applying the
|
|
268
|
+
diff — capturing it once here means later commands don't need to rediscover
|
|
269
|
+
it (which previously broke in sandboxes with no remote-tracking refs).
|
|
270
|
+
|
|
271
|
+
`session start` does not write skill files — that is `asrt install`, the
|
|
272
|
+
one-time onboarding step. It also never touches user content in `CLAUDE.md` /
|
|
273
|
+
`AGENTS.md`. It does ensure the `.gitignore` rules exist so the `.assertion`
|
|
274
|
+
state it writes below stays out of the user's commits.
|
|
275
|
+
"""
|
|
276
|
+
repo_root = find_git_root(Path.cwd())
|
|
202
277
|
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
203
278
|
|
|
204
279
|
base_sha = get_head_sha(repo_root)
|
|
@@ -296,12 +371,6 @@ def checkpoint(
|
|
|
296
371
|
|
|
297
372
|
repo_root = find_git_root(Path.cwd())
|
|
298
373
|
|
|
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
374
|
existing_metadata_path = repo_root / ASSERTION_DIR_NAME / METADATA_FILE_NAME
|
|
306
375
|
session_exists = existing_metadata_path.exists()
|
|
307
376
|
|
|
@@ -332,10 +401,10 @@ def checkpoint(
|
|
|
332
401
|
assert stack_id is not None
|
|
333
402
|
|
|
334
403
|
append_checkpoint_entry(ctx.checkpoint_path, message)
|
|
335
|
-
# Base SHA is whatever `asrt
|
|
336
|
-
# it as the checkout target before applying the diff. Pinning it at
|
|
337
|
-
# time (instead of rediscovering per command) means sandboxes
|
|
338
|
-
# remote-tracking refs — e.g. Codex Cloud — still work.
|
|
404
|
+
# Base SHA is whatever `asrt session start` recorded for this repo. Backend
|
|
405
|
+
# uses it as the checkout target before applying the diff. Pinning it at
|
|
406
|
+
# session-start time (instead of rediscovering per command) means sandboxes
|
|
407
|
+
# without remote-tracking refs — e.g. Codex Cloud — still work.
|
|
339
408
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
340
409
|
head_branch = get_head_branch(ctx.repo_root)
|
|
341
410
|
diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
|
|
@@ -436,8 +505,8 @@ def verify(
|
|
|
436
505
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
437
506
|
stack_id = metadata.stack_id
|
|
438
507
|
assert stack_id is not None
|
|
439
|
-
# See checkpoint() for why the base is the SHA `asrt
|
|
440
|
-
# not local HEAD.
|
|
508
|
+
# See checkpoint() for why the base is the SHA `asrt session start`
|
|
509
|
+
# recorded, not local HEAD.
|
|
441
510
|
base_sha = read_init_base_sha(ctx.assertion_dir)
|
|
442
511
|
head_branch = get_head_branch(ctx.repo_root)
|
|
443
512
|
diff_text = get_uncommitted_diff(ctx.repo_root, base_sha)
|
|
@@ -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,31 @@ 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
|
-
Exits with a clear error if
|
|
23
|
-
The base SHA is the commit the backend `git checkout`s against
|
|
24
|
-
applying the diff, so every checkpoint/verify must have it.
|
|
22
|
+
Exits with a clear error if `asrt session start` hasn't been run or the file
|
|
23
|
+
is empty. The base SHA is the commit the backend `git checkout`s against
|
|
24
|
+
before applying the diff, so every checkpoint/verify must have it.
|
|
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 HEAD SHA that checkpoint/verify diff against."
|
|
29
|
+
"No diff base recorded for this repo. Run `asrt session start` "
|
|
30
|
+
"first — it captures the HEAD SHA that checkpoint/verify diff against."
|
|
31
31
|
)
|
|
32
32
|
content = path.read_text(encoding="utf-8").strip()
|
|
33
33
|
if not content:
|
|
34
34
|
exit_with_error(
|
|
35
|
-
f"{path.name} is empty. Re-run `asrt
|
|
35
|
+
f"{path.name} is empty. Re-run `asrt session start` to record the "
|
|
36
|
+
"diff base."
|
|
36
37
|
)
|
|
37
38
|
return content
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
def _require_repo_ready(start_path: Path) -> Path:
|
|
41
42
|
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.
|
|
43
|
+
# Fail fast if `asrt session start` hasn't run — checkpoint/verify need the
|
|
44
|
+
# base SHA it records before doing any work or making any network calls.
|
|
44
45
|
read_init_base_sha(repo_root / ASSERTION_DIR_NAME)
|
|
45
46
|
return repo_root
|
|
46
47
|
|
|
@@ -193,8 +194,8 @@ def update_metadata_anchor(
|
|
|
193
194
|
) -> MetadataPayload:
|
|
194
195
|
"""Refresh the per-session git anchor: diff base + branch name.
|
|
195
196
|
|
|
196
|
-
`base_sha` is the diff base (recorded by `asrt
|
|
197
|
-
call within a session). `head_branch` is the current local branch — may
|
|
197
|
+
`base_sha` is the diff base (recorded by `asrt session start`, same value on
|
|
198
|
+
every call within a session). `head_branch` is the current local branch — may
|
|
198
199
|
legitimately change within a session if the user renames or switches.
|
|
199
200
|
"""
|
|
200
201
|
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
|
---
|
|
@@ -255,3 +255,57 @@ def test_get_uncommitted_diff_ends_with_newline_when_not_empty(tmp_path: Path) -
|
|
|
255
255
|
diff2 = get_uncommitted_diff(tmp_path, "HEAD")
|
|
256
256
|
assert diff2 != ""
|
|
257
257
|
assert diff2.endswith("\n"), f"untracked diff should end with newline, got: {diff2[-10:]!r}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_get_uncommitted_diff_preserves_trailing_blank_context_lines(
|
|
261
|
+
tmp_path: Path,
|
|
262
|
+
) -> None:
|
|
263
|
+
"""Blank context lines at EOF are patch content and must not be stripped."""
|
|
264
|
+
_init_repo_with_commit(tmp_path)
|
|
265
|
+
source = tmp_path / "crypto.ts"
|
|
266
|
+
source.write_text(
|
|
267
|
+
"\n".join(
|
|
268
|
+
[
|
|
269
|
+
"const nonce = bytes.slice(0, 12);",
|
|
270
|
+
"const plaintext = decrypt(oldParams(nonce));",
|
|
271
|
+
"",
|
|
272
|
+
"",
|
|
273
|
+
]
|
|
274
|
+
),
|
|
275
|
+
encoding="utf-8",
|
|
276
|
+
)
|
|
277
|
+
subprocess.run(["git", "add", "crypto.ts"], cwd=tmp_path, check=True)
|
|
278
|
+
subprocess.run(
|
|
279
|
+
["git", "commit", "-m", "add crypto"],
|
|
280
|
+
cwd=tmp_path,
|
|
281
|
+
capture_output=True,
|
|
282
|
+
check=True,
|
|
283
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
284
|
+
)
|
|
285
|
+
base_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
286
|
+
|
|
287
|
+
source.write_text(
|
|
288
|
+
"\n".join(
|
|
289
|
+
[
|
|
290
|
+
"const nonce = bytes.slice(0, 12);",
|
|
291
|
+
"const plaintext = decrypt(newParams(nonce));",
|
|
292
|
+
"",
|
|
293
|
+
"",
|
|
294
|
+
]
|
|
295
|
+
),
|
|
296
|
+
encoding="utf-8",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
diff = get_uncommitted_diff(tmp_path, base_sha)
|
|
300
|
+
|
|
301
|
+
assert diff.endswith(" \n")
|
|
302
|
+
subprocess.run(["git", "checkout", "--", "crypto.ts"], cwd=tmp_path, check=True)
|
|
303
|
+
apply_check = subprocess.run(
|
|
304
|
+
["git", "apply", "--check", "-"],
|
|
305
|
+
input=diff,
|
|
306
|
+
cwd=tmp_path,
|
|
307
|
+
capture_output=True,
|
|
308
|
+
text=True,
|
|
309
|
+
check=False,
|
|
310
|
+
)
|
|
311
|
+
assert apply_check.returncode == 0, apply_check.stderr
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from typer.testing import CliRunner
|
|
8
|
+
|
|
9
|
+
import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
runner = CliRunner()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _init_repo(tmp_path: Path) -> None:
|
|
16
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_install_writes_all_skill_destinations(tmp_path: Path, monkeypatch) -> None:
|
|
20
|
+
_init_repo(tmp_path)
|
|
21
|
+
monkeypatch.chdir(tmp_path)
|
|
22
|
+
|
|
23
|
+
result = runner.invoke(main.app, ["install"])
|
|
24
|
+
assert result.exit_code == 0, result.stderr
|
|
25
|
+
|
|
26
|
+
# Claude Code: skill registry
|
|
27
|
+
claude_skill = tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md"
|
|
28
|
+
assert claude_skill.exists()
|
|
29
|
+
assert "name: assertion-cli" in claude_skill.read_text()
|
|
30
|
+
|
|
31
|
+
# Codex + Cursor: shared skill registry, byte-identical
|
|
32
|
+
agents_skill = tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md"
|
|
33
|
+
assert agents_skill.exists()
|
|
34
|
+
assert agents_skill.read_text() == claude_skill.read_text()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_install_writes_agent_doc_pointers(tmp_path: Path, monkeypatch) -> None:
|
|
38
|
+
_init_repo(tmp_path)
|
|
39
|
+
monkeypatch.chdir(tmp_path)
|
|
40
|
+
|
|
41
|
+
result = runner.invoke(main.app, ["install"])
|
|
42
|
+
assert result.exit_code == 0, result.stderr
|
|
43
|
+
|
|
44
|
+
for name in ("AGENTS.md", "CLAUDE.md"):
|
|
45
|
+
text = (tmp_path / name).read_text()
|
|
46
|
+
assert main._AGENT_BLOCK_START in text
|
|
47
|
+
assert main._AGENT_BLOCK_END in text
|
|
48
|
+
assert "assertion-cli" in text
|
|
49
|
+
assert "asrt prompt" in text
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_install_preserves_existing_agent_doc_content(
|
|
53
|
+
tmp_path: Path, monkeypatch
|
|
54
|
+
) -> None:
|
|
55
|
+
"""install appends its block without clobbering the user's own content."""
|
|
56
|
+
_init_repo(tmp_path)
|
|
57
|
+
monkeypatch.chdir(tmp_path)
|
|
58
|
+
|
|
59
|
+
user_claude = "# My CLAUDE.md\n\nCustom rules.\n"
|
|
60
|
+
(tmp_path / "CLAUDE.md").write_text(user_claude)
|
|
61
|
+
|
|
62
|
+
result = runner.invoke(main.app, ["install"])
|
|
63
|
+
assert result.exit_code == 0, result.stderr
|
|
64
|
+
|
|
65
|
+
text = (tmp_path / "CLAUDE.md").read_text()
|
|
66
|
+
assert text.startswith(user_claude.rstrip("\n"))
|
|
67
|
+
assert main._AGENT_BLOCK_START in text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_install_replaces_stale_block_in_place(tmp_path: Path, monkeypatch) -> None:
|
|
71
|
+
"""Re-installing replaces only the delimited block, not user content."""
|
|
72
|
+
_init_repo(tmp_path)
|
|
73
|
+
monkeypatch.chdir(tmp_path)
|
|
74
|
+
|
|
75
|
+
stale = (
|
|
76
|
+
"# My doc\n\n"
|
|
77
|
+
f"{main._AGENT_BLOCK_START}\n"
|
|
78
|
+
"OLD STALE ASSERTION CONTENT\n"
|
|
79
|
+
f"{main._AGENT_BLOCK_END}\n\n"
|
|
80
|
+
"Footer the user wrote.\n"
|
|
81
|
+
)
|
|
82
|
+
(tmp_path / "AGENTS.md").write_text(stale)
|
|
83
|
+
|
|
84
|
+
result = runner.invoke(main.app, ["install"])
|
|
85
|
+
assert result.exit_code == 0, result.stderr
|
|
86
|
+
|
|
87
|
+
text = (tmp_path / "AGENTS.md").read_text()
|
|
88
|
+
assert "OLD STALE ASSERTION CONTENT" not in text
|
|
89
|
+
assert "# My doc" in text
|
|
90
|
+
assert "Footer the user wrote." in text
|
|
91
|
+
assert text.count(main._AGENT_BLOCK_START) == 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_install_is_idempotent(tmp_path: Path, monkeypatch) -> None:
|
|
95
|
+
_init_repo(tmp_path)
|
|
96
|
+
monkeypatch.chdir(tmp_path)
|
|
97
|
+
|
|
98
|
+
runner.invoke(main.app, ["install"])
|
|
99
|
+
tracked = [
|
|
100
|
+
".claude/skills/assertion-cli/SKILL.md",
|
|
101
|
+
".agents/skills/assertion-cli/SKILL.md",
|
|
102
|
+
"AGENTS.md",
|
|
103
|
+
"CLAUDE.md",
|
|
104
|
+
]
|
|
105
|
+
snapshots = {p: (tmp_path / p).read_text() for p in tracked}
|
|
106
|
+
|
|
107
|
+
runner.invoke(main.app, ["install"])
|
|
108
|
+
for rel, before in snapshots.items():
|
|
109
|
+
assert (tmp_path / rel).read_text() == before, rel
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_install_ensures_gitignore(tmp_path: Path, monkeypatch) -> None:
|
|
113
|
+
_init_repo(tmp_path)
|
|
114
|
+
monkeypatch.chdir(tmp_path)
|
|
115
|
+
|
|
116
|
+
result = runner.invoke(main.app, ["install"])
|
|
117
|
+
assert result.exit_code == 0, result.stderr
|
|
118
|
+
|
|
119
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
120
|
+
"# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_install_records_skill_cleanup_guidance(tmp_path: Path, monkeypatch) -> None:
|
|
125
|
+
"""The installed skill carries the fresh-session cleanup workflow text."""
|
|
126
|
+
_init_repo(tmp_path)
|
|
127
|
+
monkeypatch.chdir(tmp_path)
|
|
128
|
+
|
|
129
|
+
result = runner.invoke(main.app, ["install"])
|
|
130
|
+
assert result.exit_code == 0, result.stderr
|
|
131
|
+
|
|
132
|
+
skill = (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").read_text()
|
|
133
|
+
assert "First command sequence for a new work session" in skill
|
|
134
|
+
assert (
|
|
135
|
+
'ask the user: "Do you want to clean up the previous Assertion session '
|
|
136
|
+
'and start fresh?"'
|
|
137
|
+
) in skill
|
|
138
|
+
assert "Do not run `asrt new` without explicit user consent" in skill
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_install_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
142
|
+
monkeypatch.chdir(tmp_path)
|
|
143
|
+
|
|
144
|
+
result = runner.invoke(main.app, ["install"])
|
|
145
|
+
assert result.exit_code == 1
|
|
146
|
+
assert "git repository" in result.stderr
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_checkpoint_does_not_write_skill_files() -> None:
|
|
150
|
+
"""Skill files are written at install, never on a regular agent run.
|
|
151
|
+
|
|
152
|
+
`asrt checkpoint` runs on every trajectory-feedback moment; refreshing the
|
|
153
|
+
skill files there is exactly the per-run behavior we moved to `asrt install`.
|
|
154
|
+
"""
|
|
155
|
+
assert "_refresh_skill_files" not in inspect.getsource(main.checkpoint)
|
|
@@ -0,0 +1,225 @@
|
|
|
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 session start` records HEAD as the diff base, so tests need a
|
|
27
|
+
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_session_start_does_not_write_skill_or_agent_files(
|
|
40
|
+
tmp_path: Path, monkeypatch
|
|
41
|
+
) -> None:
|
|
42
|
+
"""`asrt session start` is the per-session diff-base step — not scaffolding.
|
|
43
|
+
|
|
44
|
+
Skill files and the AGENTS.md / CLAUDE.md pointer are written by
|
|
45
|
+
`asrt install`, never by `session start` (and never on a regular agent run).
|
|
46
|
+
"""
|
|
47
|
+
_init_repo(tmp_path)
|
|
48
|
+
monkeypatch.chdir(tmp_path)
|
|
49
|
+
|
|
50
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
51
|
+
assert result.exit_code == 0, result.stderr
|
|
52
|
+
|
|
53
|
+
assert not (tmp_path / ".claude" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
54
|
+
assert not (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").exists()
|
|
55
|
+
assert not (tmp_path / "CLAUDE.md").exists()
|
|
56
|
+
assert not (tmp_path / "AGENTS.md").exists()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
|
|
60
|
+
tmp_path: Path, monkeypatch
|
|
61
|
+
) -> None:
|
|
62
|
+
_init_repo(tmp_path)
|
|
63
|
+
monkeypatch.chdir(tmp_path)
|
|
64
|
+
|
|
65
|
+
existing_gitignore = "*.pyc\n"
|
|
66
|
+
(tmp_path / ".gitignore").write_text(existing_gitignore)
|
|
67
|
+
(tmp_path / ".assertion").mkdir()
|
|
68
|
+
(tmp_path / ".assertion" / "base_sha").write_text("local state\n")
|
|
69
|
+
(tmp_path / ".assertion" / "seed.py").write_text(
|
|
70
|
+
"from assertion_seed import seed\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
74
|
+
assert result.exit_code == 0, result.stderr
|
|
75
|
+
|
|
76
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
77
|
+
"*.pyc\n\n# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
ignored_state = subprocess.run(
|
|
81
|
+
["git", "check-ignore", ".assertion/base_sha"],
|
|
82
|
+
cwd=tmp_path,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
)
|
|
85
|
+
trackable_seed = subprocess.run(
|
|
86
|
+
["git", "check-ignore", ".assertion/seed.py"],
|
|
87
|
+
cwd=tmp_path,
|
|
88
|
+
capture_output=True,
|
|
89
|
+
)
|
|
90
|
+
assert ignored_state.returncode == 0
|
|
91
|
+
assert trackable_seed.returncode == 1
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_session_start_creates_gitignore_for_assertion_state(
|
|
95
|
+
tmp_path: Path, monkeypatch
|
|
96
|
+
) -> None:
|
|
97
|
+
_init_repo(tmp_path)
|
|
98
|
+
monkeypatch.chdir(tmp_path)
|
|
99
|
+
|
|
100
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
101
|
+
assert result.exit_code == 0, result.stderr
|
|
102
|
+
|
|
103
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
104
|
+
"# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_session_start_preserves_broad_assertion_gitignore_entry(
|
|
109
|
+
tmp_path: Path, monkeypatch
|
|
110
|
+
) -> None:
|
|
111
|
+
_init_repo(tmp_path)
|
|
112
|
+
monkeypatch.chdir(tmp_path)
|
|
113
|
+
(tmp_path / ".gitignore").write_text(".assertion/\n")
|
|
114
|
+
|
|
115
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
116
|
+
assert result.exit_code == 0, result.stderr
|
|
117
|
+
|
|
118
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
119
|
+
".assertion/\n\n"
|
|
120
|
+
"# Assertion CLI local state\n"
|
|
121
|
+
".assertion/*\n"
|
|
122
|
+
"!.assertion/seed.py\n"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_session_start_adds_only_missing_assertion_gitignore_rules(
|
|
127
|
+
tmp_path: Path, monkeypatch
|
|
128
|
+
) -> None:
|
|
129
|
+
_init_repo(tmp_path)
|
|
130
|
+
monkeypatch.chdir(tmp_path)
|
|
131
|
+
(tmp_path / ".gitignore").write_text("# Assertion CLI local state\n.assertion/*\n")
|
|
132
|
+
|
|
133
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
134
|
+
assert result.exit_code == 0, result.stderr
|
|
135
|
+
|
|
136
|
+
assert (tmp_path / ".gitignore").read_text() == (
|
|
137
|
+
"# Assertion CLI local state\n.assertion/*\n\n!.assertion/seed.py\n"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_session_start_leaves_gitignore_unchanged_when_rules_exist(
|
|
142
|
+
tmp_path: Path, monkeypatch
|
|
143
|
+
) -> None:
|
|
144
|
+
_init_repo(tmp_path)
|
|
145
|
+
monkeypatch.chdir(tmp_path)
|
|
146
|
+
existing_gitignore = (
|
|
147
|
+
"*.pyc\n\n# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
|
|
148
|
+
)
|
|
149
|
+
(tmp_path / ".gitignore").write_text(existing_gitignore)
|
|
150
|
+
|
|
151
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
152
|
+
assert result.exit_code == 0, result.stderr
|
|
153
|
+
|
|
154
|
+
assert (tmp_path / ".gitignore").read_text() == existing_gitignore
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_session_start_leaves_existing_activation_files_untouched(
|
|
158
|
+
tmp_path: Path, monkeypatch
|
|
159
|
+
) -> None:
|
|
160
|
+
"""`session start` must not modify user-owned CLAUDE.md / AGENTS.md.
|
|
161
|
+
|
|
162
|
+
The base SHA is recorded here, so any edit to user-tracked docs would
|
|
163
|
+
surface in every checkpoint/verify diff as unrelated bootstrap noise. The
|
|
164
|
+
assertion-cli pointer block belongs to `asrt install`, committed before work
|
|
165
|
+
starts.
|
|
166
|
+
"""
|
|
167
|
+
_init_repo(tmp_path)
|
|
168
|
+
monkeypatch.chdir(tmp_path)
|
|
169
|
+
|
|
170
|
+
existing_claude = "# My existing CLAUDE.md\n\nSome custom instructions.\n"
|
|
171
|
+
existing_agents = "# Project guidelines\n\nLint with ruff.\n"
|
|
172
|
+
(tmp_path / "CLAUDE.md").write_text(existing_claude)
|
|
173
|
+
(tmp_path / "AGENTS.md").write_text(existing_agents)
|
|
174
|
+
|
|
175
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
176
|
+
assert result.exit_code == 0
|
|
177
|
+
|
|
178
|
+
assert (tmp_path / "CLAUDE.md").read_text() == existing_claude
|
|
179
|
+
assert (tmp_path / "AGENTS.md").read_text() == existing_agents
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_session_start_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
183
|
+
monkeypatch.chdir(tmp_path)
|
|
184
|
+
|
|
185
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
186
|
+
assert result.exit_code == 1
|
|
187
|
+
assert "git repository" in result.stderr
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_session_start_records_head_sha_as_base(tmp_path: Path, monkeypatch) -> None:
|
|
191
|
+
"""`asrt session start` pins the diff base for this repo to current HEAD.
|
|
192
|
+
|
|
193
|
+
Every subsequent checkpoint/verify diffs against this SHA, so backend
|
|
194
|
+
`git checkout` always targets a commit the user/agent has committed at
|
|
195
|
+
least once locally (and presumably pushed).
|
|
196
|
+
"""
|
|
197
|
+
_init_repo(tmp_path)
|
|
198
|
+
monkeypatch.chdir(tmp_path)
|
|
199
|
+
|
|
200
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
201
|
+
assert result.exit_code == 0, result.stderr
|
|
202
|
+
|
|
203
|
+
head_sha = subprocess.run(
|
|
204
|
+
["git", "rev-parse", "HEAD"],
|
|
205
|
+
cwd=tmp_path,
|
|
206
|
+
capture_output=True,
|
|
207
|
+
text=True,
|
|
208
|
+
check=True,
|
|
209
|
+
).stdout.strip()
|
|
210
|
+
|
|
211
|
+
base_sha_path = tmp_path / ".assertion" / "base_sha"
|
|
212
|
+
assert base_sha_path.exists()
|
|
213
|
+
assert base_sha_path.read_text().strip() == head_sha
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_session_start_errors_when_repo_has_no_commits(
|
|
217
|
+
tmp_path: Path, monkeypatch
|
|
218
|
+
) -> None:
|
|
219
|
+
"""No HEAD → no base SHA to record → `session start` should fail cleanly."""
|
|
220
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
221
|
+
monkeypatch.chdir(tmp_path)
|
|
222
|
+
|
|
223
|
+
result = runner.invoke(main.app, ["session", "start"])
|
|
224
|
+
assert result.exit_code == 1
|
|
225
|
+
assert not (tmp_path / ".assertion" / "base_sha").exists()
|
|
@@ -1,320 +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
|
-
|
|
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()
|
|
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
|