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.
Files changed (32) hide show
  1. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/PKG-INFO +3 -1
  2. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/README.md +2 -0
  3. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/PKG-INFO +3 -1
  4. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/SOURCES.txt +4 -2
  5. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/git.py +23 -11
  6. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/main.py +102 -33
  7. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/models.py +1 -1
  8. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/pyproject.toml +1 -1
  9. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/session.py +12 -11
  10. assertion_cli-0.5.8/templates/AGENTS.md +14 -0
  11. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/templates/SKILL.md +3 -2
  12. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_git.py +54 -0
  13. assertion_cli-0.5.8/tests/test_install.py +155 -0
  14. assertion_cli-0.5.8/tests/test_session_start.py +225 -0
  15. assertion_cli-0.5.7/tests/test_init.py +0 -320
  16. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/api.py +0 -0
  17. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/dependency_links.txt +0 -0
  18. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/entry_points.txt +0 -0
  19. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/requires.txt +0 -0
  20. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/assertion_cli.egg-info/top_level.txt +0 -0
  21. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/bundle.py +0 -0
  22. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/link.py +0 -0
  23. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/setup.cfg +0 -0
  24. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/templates/__init__.py +0 -0
  25. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_api.py +0 -0
  26. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_bundle.py +0 -0
  27. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_decision.py +0 -0
  28. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_link.py +0 -0
  29. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_main.py +0 -0
  30. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_new.py +0 -0
  31. {assertion_cli-0.5.7 → assertion_cli-0.5.8}/tests/test_prompt.py +0 -0
  32. {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.7
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.7
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/test_init.py
23
+ tests/test_install.py
23
24
  tests/test_link.py
24
25
  tests/test_main.py
25
26
  tests/test_new.py
26
27
  tests/test_prompt.py
27
- tests/test_session.py
28
+ tests/test_session.py
29
+ tests/test_session_start.py
@@ -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(repo_root: Path, args: Sequence[str]) -> str:
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.strip()
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/refreshed by `asrt init` and `asrt checkpoint` and have nothing to
82
- # do with the user's feature work. CLAUDE.md / AGENTS.md are intentionally
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.
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, ["diff", base_sha, "--", *_ASSERTION_EXCLUDED_PATHSPECS]
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 = [p for p in [tracked, "\n".join(untracked_diffs)] if p]
123
- diff = "\n".join(parts)
124
- return diff + "\n" if diff else diff
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
- """Write/refresh the assertion-owned skill files for each coding agent.
124
+ """Overwrite the assertion-owned skill files with the bundled template.
125
125
 
126
- Safe to call on every checkpoint these files are entirely ours; updating
127
- them to match the installed CLI version cannot clobber user content.
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("init")
179
- def init() -> None:
180
- """One-time bootstrap: install the assertion-cli skill files into this repo.
228
+ @app.command("install")
229
+ def install() -> None:
230
+ """One-time onboarding: scaffold the Assertion skill + agent docs into this repo.
181
231
 
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.
232
+ Run this once after `uv tool install assertion-cli`. It writes:
188
233
 
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).
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
- Also records the current HEAD SHA as the diff base for all future checkpoint/verify
196
- calls in this repo. The base is what the backend `git checkout`s against before
197
- applying the diff capturing it once at init means later commands don't need to
198
- rediscover it (which previously broke in sandboxes with no remote-tracking refs).
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 init` recorded for this repo. Backend uses
336
- # it as the checkout target before applying the diff. Pinning it at init
337
- # time (instead of rediscovering per command) means sandboxes without
338
- # remote-tracking refs — e.g. Codex Cloud — still work.
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 init` recorded,
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 init` recorded; the commit
67
+ `base_sha` is the diff base (the commit `asrt session start` recorded; the commit
68
68
  the backend checks out before applying the diff). In the bundle sent to
69
69
  the backend it is remapped to `head_sha` for wire compatibility — from
70
70
  the backend's perspective, after `git checkout <sha>`, that SHA IS its
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "assertion-cli"
7
- version = "0.5.7"
7
+ version = "0.5.8"
8
8
  description = "CLI for the Assertion API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -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 init`.
20
+ """Return the diff base SHA recorded by `asrt session start`.
21
21
 
22
- Exits with a clear error if init hasn't been run or the file is empty.
23
- The base SHA is the commit the backend `git checkout`s against before
24
- applying the diff, so every checkpoint/verify must have it.
22
+ 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 init` first — "
30
- "it captures the HEAD SHA that checkpoint/verify diff against."
29
+ "No diff base recorded for this repo. Run `asrt session start` "
30
+ "first — it captures the 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 init` to record the diff base."
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 init` hasn't run — checkpoint/verify need the base
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 init`, same value on every
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 init` has been run in this repo. It records the current HEAD as the diff base that every checkpoint/verify diffs against, and installs the skill files you are reading right now. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt init` and retry.
17
+ - `asrt install` has been run once in this repo. It installs the skill files you are reading right now (plus the `AGENTS.md` / `CLAUDE.md` pointer and `.gitignore` rules). This is an onboarding step, not a per-run one.
18
+ - `asrt session start` has been run for this work session. It records the current HEAD as the diff base that every checkpoint/verify diffs against. If `asrt checkpoint` or `asrt verify` errors with "No diff base recorded", run `asrt session start` and retry.
18
19
 
19
20
  **First command sequence for a new work session:**
20
21
 
@@ -118,7 +119,7 @@ When you pass `--stack`, briefly state which stack you chose and why ("Picked st
118
119
  | `No session exists yet and no --stack given.` | First checkpoint of a fresh `.assertion/`, but you didn't pick a stack. | Run `asrt stacks`, pick one using the signals above, pass `--stack <id>`. |
119
120
  | `This session is already anchored to a stack.` | You passed `--stack` after a session was already created. The CLI locks the stack at session creation. | Drop the `--stack` flag and re-run `asrt checkpoint "<message>"`, or ask the user to run `asrt new` if this should be a fresh session. |
120
121
  | `ERROR: Unknown stack '<id>'.` | The `--stack` you passed is not in the workspace. | Re-run `asrt stacks` (the list may have changed) and pass a valid id. |
121
- | `No diff base recorded for this repo.` | `asrt init` wasn't run. | Run `asrt init` and retry. |
122
+ | `No diff base recorded for this repo.` | `asrt session start` wasn't run. | Run `asrt session start` and retry. |
122
123
  | `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
123
124
 
124
125
  ---
@@ -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