assertion-cli 0.5.11__tar.gz → 0.5.13__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.11 → assertion_cli-0.5.13}/PKG-INFO +1 -1
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/PKG-INFO +1 -1
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/SOURCES.txt +0 -1
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/bundle.py +8 -11
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/git.py +49 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/main.py +101 -61
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/models.py +7 -7
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/pyproject.toml +1 -1
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/session.py +20 -19
- assertion_cli-0.5.13/templates/AGENTS.md +26 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/templates/SKILL.md +10 -16
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_bundle.py +10 -9
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_git.py +135 -2
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_install.py +2 -5
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_session.py +9 -5
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_session_start.py +44 -10
- assertion_cli-0.5.11/templates/AGENTS.md +0 -14
- assertion_cli-0.5.11/tests/test_new.py +0 -142
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/README.md +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/api.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/dependency_links.txt +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/entry_points.txt +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/requires.txt +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/assertion_cli.egg-info/top_level.txt +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/link.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/setup.cfg +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/templates/__init__.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_api.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_decision.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_link.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_main.py +0 -0
- {assertion_cli-0.5.11 → assertion_cli-0.5.13}/tests/test_prompt.py +0 -0
|
@@ -6,24 +6,21 @@ from models import MetadataPayload
|
|
|
6
6
|
|
|
7
7
|
ASSERTION_DIR_NAME = ".assertion"
|
|
8
8
|
DIFF_ARCHIVE_PATH = "git.diff"
|
|
9
|
-
|
|
9
|
+
REVIEW_DIFF_ARCHIVE_PATH = "review.diff"
|
|
10
10
|
METADATA_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/metadata.json"
|
|
11
11
|
PROMPTS_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/prompts"
|
|
12
12
|
CHECKPOINT_ARCHIVE_PATH = f"{ASSERTION_DIR_NAME}/checkpoint"
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
def _metadata_wire_json(metadata: MetadataPayload) -> str:
|
|
16
|
-
"""Serialize metadata for the bundle
|
|
16
|
+
"""Serialize metadata for the bundle.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
that SHA literally is the clone's HEAD. Same value, different name per
|
|
22
|
-
side; the remap lives here so neither side has to know about the other's
|
|
23
|
-
perspective.
|
|
18
|
+
`clone_sha` is the remote commit the backend sandbox checks out, so it is
|
|
19
|
+
emitted as `head_sha` on the wire. `base_sha` is the verification base
|
|
20
|
+
(where the user wants to start verifying from) and is emitted as-is.
|
|
24
21
|
"""
|
|
25
22
|
payload = metadata.model_dump()
|
|
26
|
-
payload["head_sha"] = payload.pop("
|
|
23
|
+
payload["head_sha"] = payload.pop("clone_sha", None)
|
|
27
24
|
return json.dumps(payload, indent=2) + "\n"
|
|
28
25
|
|
|
29
26
|
|
|
@@ -31,7 +28,7 @@ def build_bundle(
|
|
|
31
28
|
*,
|
|
32
29
|
metadata: MetadataPayload,
|
|
33
30
|
diff_text: str,
|
|
34
|
-
|
|
31
|
+
review_diff_text: str,
|
|
35
32
|
prompts_text: str,
|
|
36
33
|
checkpoint_text: str,
|
|
37
34
|
) -> bytes:
|
|
@@ -41,5 +38,5 @@ def build_bundle(
|
|
|
41
38
|
zf.writestr(PROMPTS_ARCHIVE_PATH, prompts_text)
|
|
42
39
|
zf.writestr(CHECKPOINT_ARCHIVE_PATH, checkpoint_text)
|
|
43
40
|
zf.writestr(DIFF_ARCHIVE_PATH, diff_text)
|
|
44
|
-
zf.writestr(
|
|
41
|
+
zf.writestr(REVIEW_DIFF_ARCHIVE_PATH, review_diff_text)
|
|
45
42
|
return buf.getvalue()
|
|
@@ -132,6 +132,55 @@ def get_merge_base(repo_root: Path, upstream: str) -> str:
|
|
|
132
132
|
)
|
|
133
133
|
|
|
134
134
|
|
|
135
|
+
def is_ancestor(repo_root: Path, maybe_ancestor: str, descendant: str) -> bool:
|
|
136
|
+
"""Return True if *maybe_ancestor* is an ancestor of *descendant*."""
|
|
137
|
+
try:
|
|
138
|
+
run_git_command(
|
|
139
|
+
repo_root, ["merge-base", "--is-ancestor", maybe_ancestor, descendant]
|
|
140
|
+
)
|
|
141
|
+
return True
|
|
142
|
+
except RuntimeError:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_remote_ancestor(repo_root: Path, sha: str) -> str | None:
|
|
147
|
+
"""Return the newest remote commit that is an ancestor of *sha*.
|
|
148
|
+
|
|
149
|
+
Uses local remote-tracking refs so this works offline. Returns ``None``
|
|
150
|
+
when no remote-tracking ref contains an ancestor of *sha*.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
merged = run_git_command(
|
|
154
|
+
repo_root,
|
|
155
|
+
["branch", "-r", "--merged", sha, "--format=%(refname:short)"],
|
|
156
|
+
)
|
|
157
|
+
except RuntimeError:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
candidates = [r for r in merged.splitlines() if r]
|
|
161
|
+
if not candidates:
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
newest_ref: str | None = None
|
|
165
|
+
newest_ts = -1
|
|
166
|
+
for ref in candidates:
|
|
167
|
+
try:
|
|
168
|
+
ts = int(run_git_command(repo_root, ["log", "-1", "--format=%ct", ref]))
|
|
169
|
+
except (RuntimeError, ValueError):
|
|
170
|
+
continue
|
|
171
|
+
if ts > newest_ts:
|
|
172
|
+
newest_ts = ts
|
|
173
|
+
newest_ref = ref
|
|
174
|
+
|
|
175
|
+
if newest_ref is None:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
return run_git_command(repo_root, ["merge-base", sha, newest_ref])
|
|
180
|
+
except RuntimeError:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
|
|
135
184
|
def get_head_branch(repo_root: Path) -> str | None:
|
|
136
185
|
"""Return the current branch name, or None if HEAD is detached."""
|
|
137
186
|
try:
|
|
@@ -17,22 +17,22 @@ from git import (
|
|
|
17
17
|
get_full_diff,
|
|
18
18
|
get_head_branch,
|
|
19
19
|
get_head_sha,
|
|
20
|
-
|
|
21
|
-
get_uncommitted_diff,
|
|
20
|
+
get_remote_ancestor,
|
|
22
21
|
get_upstream_ref,
|
|
22
|
+
is_ancestor,
|
|
23
23
|
run_git_command,
|
|
24
24
|
)
|
|
25
25
|
from link import load_link, save_link
|
|
26
26
|
from models import CheckpointResponse, SessionStatus, render_stack_list
|
|
27
27
|
from session import (
|
|
28
28
|
ASSERTION_DIR_NAME,
|
|
29
|
-
BASE_SHA_FILE_NAME,
|
|
30
29
|
METADATA_FILE_NAME,
|
|
31
30
|
PROMPTS_FILE_NAME,
|
|
31
|
+
VERIFY_BASE_SHA_FILE_NAME,
|
|
32
32
|
append_checkpoint_entry,
|
|
33
33
|
continue_session,
|
|
34
34
|
load_existing_session,
|
|
35
|
-
|
|
35
|
+
read_init_verify_base_sha,
|
|
36
36
|
start_new_session,
|
|
37
37
|
update_metadata_anchor,
|
|
38
38
|
)
|
|
@@ -264,18 +264,24 @@ session_app = typer.Typer(help="Manage Assertion work sessions.")
|
|
|
264
264
|
app.add_typer(session_app, name="session")
|
|
265
265
|
|
|
266
266
|
|
|
267
|
-
def
|
|
268
|
-
"""
|
|
267
|
+
def _resolve_verify_base(repo_root: Path, from_ref: str | None) -> str:
|
|
268
|
+
"""Resolve the verification base SHA.
|
|
269
269
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
merge-base that's no longer reachable from any remote ref.
|
|
274
|
-
|
|
275
|
-
Falls back to HEAD when no upstream can be resolved (e.g. Codex cloud
|
|
276
|
-
sandboxes with no remote tracking refs) — the branch was cloned from the
|
|
277
|
-
remote so HEAD exists on the remote at session start time.
|
|
270
|
+
If *from_ref* is provided, resolve it and validate that it is an ancestor
|
|
271
|
+
of HEAD. Otherwise compute the merge-base with the upstream tracking branch,
|
|
272
|
+
falling back to HEAD when no upstream is available.
|
|
278
273
|
"""
|
|
274
|
+
if from_ref is not None:
|
|
275
|
+
try:
|
|
276
|
+
sha = run_git_command(repo_root, ["rev-parse", "--verify", from_ref])
|
|
277
|
+
except RuntimeError as exc:
|
|
278
|
+
exit_with_error(f"Invalid --from ref '{from_ref}': {exc}")
|
|
279
|
+
if not is_ancestor(repo_root, sha, "HEAD"):
|
|
280
|
+
exit_with_error(
|
|
281
|
+
f"--from ref {sha[:12]} is not an ancestor of HEAD."
|
|
282
|
+
)
|
|
283
|
+
return sha
|
|
284
|
+
|
|
279
285
|
try:
|
|
280
286
|
run_git_command(repo_root, ["fetch", "--no-tags", "origin"])
|
|
281
287
|
except RuntimeError:
|
|
@@ -288,18 +294,61 @@ def _resolve_diff_base(repo_root: Path) -> str:
|
|
|
288
294
|
err=True,
|
|
289
295
|
)
|
|
290
296
|
return get_head_sha(repo_root)
|
|
291
|
-
|
|
297
|
+
try:
|
|
298
|
+
return run_git_command(repo_root, ["merge-base", "HEAD", upstream])
|
|
299
|
+
except RuntimeError as exc:
|
|
300
|
+
exit_with_error(
|
|
301
|
+
f"Failed to compute merge-base between HEAD and {upstream}: {exc}"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_clone_anchor(repo_root: Path, verify_base: str) -> str:
|
|
306
|
+
"""Return a remote commit the sandbox can check out.
|
|
307
|
+
|
|
308
|
+
If *verify_base* itself is reachable from a remote ref, use it. Otherwise
|
|
309
|
+
find the newest remote ancestor of *verify_base*. If no remote is reachable
|
|
310
|
+
at all, fall back to HEAD (the branch was cloned from the remote).
|
|
311
|
+
"""
|
|
312
|
+
ancestor = get_remote_ancestor(repo_root, verify_base)
|
|
313
|
+
if ancestor is not None:
|
|
314
|
+
return ancestor
|
|
315
|
+
|
|
316
|
+
typer.echo(
|
|
317
|
+
"Warning: no remote reachable, falling back to HEAD for clone anchor.",
|
|
318
|
+
err=True,
|
|
319
|
+
)
|
|
320
|
+
return get_head_sha(repo_root)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _reset_assertion_dir(assertion_dir: Path) -> int:
|
|
324
|
+
"""Remove all local Assertion state, keeping committed seed hooks trackable."""
|
|
325
|
+
removed = 0
|
|
326
|
+
if not assertion_dir.exists():
|
|
327
|
+
return removed
|
|
328
|
+
for child in assertion_dir.iterdir():
|
|
329
|
+
if child.name == "seed.py":
|
|
330
|
+
continue
|
|
331
|
+
if child.is_dir():
|
|
332
|
+
shutil.rmtree(child)
|
|
333
|
+
else:
|
|
334
|
+
child.unlink()
|
|
335
|
+
removed += 1
|
|
336
|
+
return removed
|
|
292
337
|
|
|
293
338
|
|
|
294
339
|
@session_app.command("start")
|
|
295
|
-
def session_start(
|
|
296
|
-
|
|
340
|
+
def session_start(
|
|
341
|
+
from_ref: str | None = typer.Option(
|
|
342
|
+
None,
|
|
343
|
+
"--from",
|
|
344
|
+
help="Verification base ref (commit/branch/tag). Defaults to merge-base with upstream.",
|
|
345
|
+
),
|
|
346
|
+
) -> None:
|
|
347
|
+
"""Start a fresh Assertion session and record the verification base.
|
|
297
348
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
branch was cloned from the remote so HEAD exists on the remote at session
|
|
302
|
-
start time.
|
|
349
|
+
Resets `.assertion/` (except `.assertion/seed.py`) so each session starts
|
|
350
|
+
clean. When no upstream tracking branch can be resolved, falls back to
|
|
351
|
+
HEAD as the verification base.
|
|
303
352
|
|
|
304
353
|
`session start` does not write skill files — that is `asrt install`, the
|
|
305
354
|
one-time onboarding step. It also never touches user content in `CLAUDE.md` /
|
|
@@ -309,14 +358,20 @@ def session_start() -> None:
|
|
|
309
358
|
repo_root = find_git_root(Path.cwd())
|
|
310
359
|
_ensure_gitignore_limits_assertion_state(repo_root)
|
|
311
360
|
|
|
312
|
-
|
|
361
|
+
verify_base = _resolve_verify_base(repo_root, from_ref)
|
|
313
362
|
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
314
363
|
assertion_dir.mkdir(exist_ok=True)
|
|
315
|
-
|
|
316
|
-
|
|
364
|
+
removed = _reset_assertion_dir(assertion_dir)
|
|
365
|
+
|
|
366
|
+
verify_base_path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
|
|
367
|
+
verify_base_path.write_text(verify_base + "\n", encoding="utf-8")
|
|
317
368
|
typer.echo(
|
|
318
|
-
f"Recorded
|
|
369
|
+
f"Recorded verification base {verify_base[:12]} → {verify_base_path.relative_to(repo_root)}"
|
|
319
370
|
)
|
|
371
|
+
if removed:
|
|
372
|
+
typer.echo(
|
|
373
|
+
f"Cleared {removed} prior .assertion entr{'y' if removed == 1 else 'ies'}."
|
|
374
|
+
)
|
|
320
375
|
|
|
321
376
|
typer.echo("")
|
|
322
377
|
typer.echo("The coding agent will now follow the Assertion workflow:")
|
|
@@ -325,31 +380,6 @@ def session_start() -> None:
|
|
|
325
380
|
typer.echo(" - asrt verify at completion, then PR")
|
|
326
381
|
|
|
327
382
|
|
|
328
|
-
@app.command("new")
|
|
329
|
-
def new() -> None:
|
|
330
|
-
"""Reset local Assertion run state and record the merge-base as the diff base."""
|
|
331
|
-
repo_root = find_git_root(Path.cwd())
|
|
332
|
-
base_sha = _resolve_diff_base(repo_root)
|
|
333
|
-
assertion_dir = repo_root / ASSERTION_DIR_NAME
|
|
334
|
-
assertion_dir.mkdir(exist_ok=True)
|
|
335
|
-
|
|
336
|
-
removed = 0
|
|
337
|
-
for child in assertion_dir.iterdir():
|
|
338
|
-
if child.is_dir():
|
|
339
|
-
shutil.rmtree(child)
|
|
340
|
-
else:
|
|
341
|
-
child.unlink()
|
|
342
|
-
removed += 1
|
|
343
|
-
|
|
344
|
-
base_sha_path = assertion_dir / BASE_SHA_FILE_NAME
|
|
345
|
-
base_sha_path.write_text(base_sha + "\n", encoding="utf-8")
|
|
346
|
-
|
|
347
|
-
typer.echo(f"Cleared {removed} .assertion entr{'y' if removed == 1 else 'ies'}.")
|
|
348
|
-
typer.echo(
|
|
349
|
-
f"Recorded diff base {base_sha[:12]} → {base_sha_path.relative_to(repo_root)}"
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
|
|
353
383
|
@app.command("prompt")
|
|
354
384
|
def prompt_cmd(
|
|
355
385
|
text: str = typer.Argument(..., help="User prompt text to record."),
|
|
@@ -434,18 +464,23 @@ def checkpoint(
|
|
|
434
464
|
assert stack_id is not None
|
|
435
465
|
|
|
436
466
|
append_checkpoint_entry(ctx.checkpoint_path, message)
|
|
437
|
-
|
|
467
|
+
verify_base = read_init_verify_base_sha(ctx.assertion_dir)
|
|
468
|
+
clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
|
|
438
469
|
head_branch = get_head_branch(ctx.repo_root)
|
|
439
|
-
diff_text = get_full_diff(ctx.repo_root,
|
|
440
|
-
|
|
470
|
+
diff_text = get_full_diff(ctx.repo_root, clone_anchor)
|
|
471
|
+
review_diff_text = get_full_diff(ctx.repo_root, verify_base)
|
|
441
472
|
metadata = update_metadata_anchor(
|
|
442
|
-
ctx.metadata_path,
|
|
473
|
+
ctx.metadata_path,
|
|
474
|
+
metadata,
|
|
475
|
+
verify_base,
|
|
476
|
+
clone_sha=clone_anchor,
|
|
477
|
+
head_branch=head_branch,
|
|
443
478
|
)
|
|
444
479
|
|
|
445
480
|
bundle_bytes = build_bundle(
|
|
446
481
|
metadata=metadata,
|
|
447
482
|
diff_text=diff_text,
|
|
448
|
-
|
|
483
|
+
review_diff_text=review_diff_text,
|
|
449
484
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
450
485
|
checkpoint_text=message,
|
|
451
486
|
)
|
|
@@ -536,18 +571,23 @@ def verify(
|
|
|
536
571
|
ctx, metadata = load_existing_session(Path.cwd())
|
|
537
572
|
stack_id = metadata.stack_id
|
|
538
573
|
assert stack_id is not None
|
|
539
|
-
|
|
574
|
+
verify_base = read_init_verify_base_sha(ctx.assertion_dir)
|
|
575
|
+
clone_anchor = _resolve_clone_anchor(ctx.repo_root, verify_base)
|
|
540
576
|
head_branch = get_head_branch(ctx.repo_root)
|
|
541
|
-
diff_text = get_full_diff(ctx.repo_root,
|
|
542
|
-
|
|
577
|
+
diff_text = get_full_diff(ctx.repo_root, clone_anchor)
|
|
578
|
+
review_diff_text = get_full_diff(ctx.repo_root, verify_base)
|
|
543
579
|
metadata = update_metadata_anchor(
|
|
544
|
-
ctx.metadata_path,
|
|
580
|
+
ctx.metadata_path,
|
|
581
|
+
metadata,
|
|
582
|
+
verify_base,
|
|
583
|
+
clone_sha=clone_anchor,
|
|
584
|
+
head_branch=head_branch,
|
|
545
585
|
)
|
|
546
586
|
|
|
547
587
|
bundle_bytes = build_bundle(
|
|
548
588
|
metadata=metadata,
|
|
549
589
|
diff_text=diff_text,
|
|
550
|
-
|
|
590
|
+
review_diff_text=review_diff_text,
|
|
551
591
|
prompts_text=ctx.prompts_path.read_text(encoding="utf-8"),
|
|
552
592
|
checkpoint_text=ctx.checkpoint_path.read_text(encoding="utf-8"),
|
|
553
593
|
)
|
|
@@ -64,13 +64,12 @@ 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
|
|
68
|
-
|
|
69
|
-
the
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
and what the value represents from the CLI's perspective.
|
|
67
|
+
`base_sha` is the verification base (the commit the user wants to start
|
|
68
|
+
verifying from). `clone_sha` is the commit the backend sandbox checks out
|
|
69
|
+
before applying the diff; it is always a remote commit (or `HEAD` as a
|
|
70
|
+
fallback when no remote is reachable). The bundle wire format maps
|
|
71
|
+
`clone_sha` to `head_sha` because from the backend's perspective that
|
|
72
|
+
SHA is the clone's HEAD.
|
|
74
73
|
"""
|
|
75
74
|
|
|
76
75
|
model_config = ConfigDict(extra="allow")
|
|
@@ -78,6 +77,7 @@ class MetadataPayload(BaseModel):
|
|
|
78
77
|
session_id: str
|
|
79
78
|
stack_id: str | None = None
|
|
80
79
|
base_sha: str | None = None
|
|
80
|
+
clone_sha: str | None = None
|
|
81
81
|
head_branch: str | None = None
|
|
82
82
|
|
|
83
83
|
|
|
@@ -13,28 +13,26 @@ ASSERTION_DIR_NAME = ".assertion"
|
|
|
13
13
|
PROMPTS_FILE_NAME = "prompts"
|
|
14
14
|
CHECKPOINT_FILE_NAME = "checkpoint"
|
|
15
15
|
METADATA_FILE_NAME = "metadata.json"
|
|
16
|
-
|
|
16
|
+
VERIFY_BASE_SHA_FILE_NAME = "verify_base_sha"
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
20
|
-
"""Return the
|
|
19
|
+
def read_init_verify_base_sha(assertion_dir: Path) -> str:
|
|
20
|
+
"""Return the verification base SHA recorded by `asrt session start`.
|
|
21
21
|
|
|
22
|
-
The base
|
|
23
|
-
|
|
24
|
-
never changes within a session.
|
|
22
|
+
The verify base is the commit the user wants to start verifying from.
|
|
23
|
+
Every checkpoint/verify reads this value; it never changes within a session.
|
|
25
24
|
"""
|
|
26
|
-
path = assertion_dir /
|
|
25
|
+
path = assertion_dir / VERIFY_BASE_SHA_FILE_NAME
|
|
27
26
|
if not path.exists():
|
|
28
27
|
exit_with_error(
|
|
29
|
-
"No
|
|
30
|
-
"first — it captures the
|
|
31
|
-
"diff against."
|
|
28
|
+
"No verification base recorded for this repo. Run `asrt session start` "
|
|
29
|
+
"first — it captures the commit that checkpoint/verify diff against."
|
|
32
30
|
)
|
|
33
31
|
content = path.read_text(encoding="utf-8").strip()
|
|
34
32
|
if not content:
|
|
35
33
|
exit_with_error(
|
|
36
34
|
f"{path.name} is empty. Re-run `asrt session start` to record the "
|
|
37
|
-
"
|
|
35
|
+
"verification base."
|
|
38
36
|
)
|
|
39
37
|
return content
|
|
40
38
|
|
|
@@ -43,7 +41,7 @@ def _require_repo_ready(start_path: Path) -> Path:
|
|
|
43
41
|
repo_root = find_git_root(start_path)
|
|
44
42
|
# Fail fast if `asrt session start` hasn't run — checkpoint/verify need the
|
|
45
43
|
# base SHA it records before doing any work or making any network calls.
|
|
46
|
-
|
|
44
|
+
read_init_verify_base_sha(repo_root / ASSERTION_DIR_NAME)
|
|
47
45
|
return repo_root
|
|
48
46
|
|
|
49
47
|
|
|
@@ -191,16 +189,19 @@ def update_metadata_anchor(
|
|
|
191
189
|
metadata_path: Path,
|
|
192
190
|
metadata: MetadataPayload,
|
|
193
191
|
base_sha: str,
|
|
192
|
+
clone_sha: str | None = None,
|
|
194
193
|
head_branch: str | None = None,
|
|
195
194
|
) -> MetadataPayload:
|
|
196
|
-
"""Refresh the per-session git anchor: diff base + branch name.
|
|
195
|
+
"""Refresh the per-session git anchor: diff base + clone anchor + branch name.
|
|
197
196
|
|
|
198
|
-
`base_sha` is the
|
|
199
|
-
every call within a session). `
|
|
200
|
-
|
|
197
|
+
`base_sha` is the verification base (recorded by `asrt session start`, same
|
|
198
|
+
value on every call within a session). `clone_sha` is the remote commit the
|
|
199
|
+
backend checks out, recomputed per checkpoint/verify. `head_branch` is the
|
|
200
|
+
current local branch — may legitimately change within a session.
|
|
201
201
|
"""
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
202
|
+
update: dict[str, object] = {"base_sha": base_sha, "head_branch": head_branch}
|
|
203
|
+
if clone_sha is not None:
|
|
204
|
+
update["clone_sha"] = clone_sha
|
|
205
|
+
updated = metadata.model_copy(update=update)
|
|
205
206
|
metadata_path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
206
207
|
return updated
|
|
@@ -0,0 +1,26 @@
|
|
|
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, then PR with the session marker.** At completion, run `asrt verify` and poll `asrt verify-status` until clean. Then open the PR and embed the hidden session marker — `$(asrt get-link)`, an HTML comment — in its body.
|
|
13
|
+
|
|
14
|
+
**The session marker is mandatory, not optional.** It's an HTML comment carrying the session URL — `asrt get-link` prints exactly this form:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
<!-- assertion-session: https://app.tryassertion.com/sessions/<id> -->
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Every PR opened from an Assertion session **must** carry it: the Assertion GitHub bot links the PR to the verification session by branch name first, and falls back to this marker when the branch doesn't match — without it, that PR can't be linked. So:
|
|
21
|
+
|
|
22
|
+
- Put `$(asrt get-link)` in the PR body via `gh pr create`/`gh pr edit` — never paste the bare URL into the visible description.
|
|
23
|
+
- After creating or editing the PR, **confirm the marker actually landed in the raw body** (`gh pr view --json body --jq .body | grep -q 'assertion-session:'`). It won't show in the rendered description — that's expected.
|
|
24
|
+
- A missing marker is a **blocking error, not a warning**: do not consider the task done, and do not open a PR at all, until the marker is verified present. Re-run `gh pr edit` with `$(asrt get-link)` and re-check until it passes.
|
|
25
|
+
|
|
26
|
+
See the skill file for the full rules, edge cases, and the exact PR command (section: *Embed the hidden session marker in the PR*).
|
|
@@ -15,20 +15,14 @@ Before any `asrt` call:
|
|
|
15
15
|
|
|
16
16
|
- You are inside a git repository.
|
|
17
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 merge-base with the upstream tracking branch
|
|
18
|
+
- `asrt session start` has been run for this work session. It resets `.assertion/` (except `.assertion/seed.py`) and records the verification base — the merge-base with the upstream tracking branch by default, or an explicit `--from <ref>`. If `asrt checkpoint` or `asrt verify` errors with "No verification base recorded", run `asrt session start` and retry.
|
|
19
19
|
|
|
20
20
|
**First command sequence for a new work session:**
|
|
21
21
|
|
|
22
|
-
1.
|
|
23
|
-
2.
|
|
24
|
-
3. When the harness supports structured prompts or selectable options, present the question with selectable `Yes` and `No` options. Do not ask the user to type `yes` or `no` unless selectable options are unavailable.
|
|
25
|
-
4. If the user answers yes, run `asrt new` before any other `asrt` command.
|
|
26
|
-
5. If the user answers no, continue with the existing session state.
|
|
27
|
-
6. After the reset decision is resolved, immediately log the active user prompt with `asrt prompt`. Treat the cleanup confirmation itself as a control response, not user intent to record, when it only answers this reset question.
|
|
22
|
+
1. Run `asrt session start` to start fresh. This clears previous in-flight Assertion state (metadata, prompts log, link, verification status, screenshots) and records the current verification base.
|
|
23
|
+
2. Immediately log the active user prompt with `asrt prompt`.
|
|
28
24
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually — use `asrt prompt` to append to the prompts log and `asrt new` to reset for a fresh session.
|
|
25
|
+
Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/verify_base_sha` as internal state owned by the CLI. Do not create, edit, or delete those files manually — use `asrt prompt` to append to the prompts log and `asrt session start` to reset for a fresh session.
|
|
32
26
|
|
|
33
27
|
`asrt stacks` is read-only and lists every verification stack in the user's workspace, along with the repo each is configured for. Run it once per session before your first checkpoint so you can pick a stack ID; do not hardcode IDs from memory.
|
|
34
28
|
|
|
@@ -42,7 +36,7 @@ Treat `.assertion/metadata.json`, `.assertion/prompts`, and `.assertion/base_sha
|
|
|
42
36
|
asrt prompt "<exact text of the user's message>"
|
|
43
37
|
```
|
|
44
38
|
|
|
45
|
-
Exception: on the first user turn of a new work session, perform the Preflight fresh-session check first
|
|
39
|
+
Exception: on the first user turn of a new work session, perform the Preflight fresh-session check first by running `asrt session start`, then immediately run `asrt prompt` for the active user message so the new/current session records the user's intent.
|
|
46
40
|
|
|
47
41
|
This applies to **every user turn**, not just the first. It applies even when the message is a one-word direction change ("nope"), a clarification ("use Postgres, not SQLite"), or a follow-up after verify ("also add dark mode").
|
|
48
42
|
|
|
@@ -117,9 +111,9 @@ When you pass `--stack`, briefly state which stack you chose and why ("Picked st
|
|
|
117
111
|
| CLI error starts with | What it means | What to do |
|
|
118
112
|
|---|---|---|
|
|
119
113
|
| `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>`. |
|
|
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
|
|
114
|
+
| `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 run `asrt session start` if this should be a fresh session. |
|
|
121
115
|
| `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. |
|
|
122
|
-
| `No
|
|
116
|
+
| `No verification base recorded for this repo.` | `asrt session start` wasn't run. | Run `asrt session start` and retry. |
|
|
123
117
|
| `ERROR: No active session to continue.` | You passed `--continue` but there is no session. | Drop `--continue` and pass `--stack <id>` to start one. |
|
|
124
118
|
|
|
125
119
|
---
|
|
@@ -205,7 +199,7 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
|
|
|
205
199
|
|
|
206
200
|
**Do not open a PR without a clean `asrt verify` / `asrt verify-status`, and do not open one whose body omits the session marker.** Do not end a session with a failed/dirty verify unless the verification service itself is unavailable.
|
|
207
201
|
|
|
208
|
-
**After the PR marker is confirmed:** ask the user whether they want to clean up the completed Assertion session and start fresh for the next session. If the user says yes, run `asrt
|
|
202
|
+
**After the PR marker is confirmed:** ask the user whether they want to clean up the completed Assertion session and start fresh for the next session. If the user says yes, run `asrt session start`. If the user says no, leave the existing `.assertion/` state intact.
|
|
209
203
|
|
|
210
204
|
---
|
|
211
205
|
|
|
@@ -213,7 +207,7 @@ A missing marker is a **blocking error**, not a warning: do not consider the PR
|
|
|
213
207
|
|
|
214
208
|
```
|
|
215
209
|
asrt prompt "<message>" # every user turn, before anything else
|
|
216
|
-
asrt
|
|
210
|
+
asrt session start [--from <ref>] # start fresh; optional explicit verification base
|
|
217
211
|
asrt stacks # list every stack — read once before first checkpoint
|
|
218
212
|
asrt checkpoint --stack <id> "..." # first checkpoint of a session
|
|
219
213
|
asrt checkpoint "..." # subsequent checkpoints — auto-continues
|
|
@@ -237,6 +231,6 @@ When running under a non-interactive harness (CI, benchmark runner, scripts):
|
|
|
237
231
|
- Re-run `asrt checkpoint "<what you changed>" --json` (auto-continues the existing session).
|
|
238
232
|
- Bound retries: stop after 3 attempts on the same failure mode, or when the same `reasoning` is returned twice in a row.
|
|
239
233
|
4. `asrt verify --json` submits and exits with a one-line JSON `{"session_id", "url", "status": "submitted"}`. Poll `asrt verify-status --json` on your own cadence — terminal output is `{"session_id", "url", "status", "message", "summary", "screenshot_count"}` with exit `1` on `failed`, `0` otherwise. Choose the poll interval to fit your harness timeouts; do not block a single shell call on verify.
|
|
240
|
-
5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch first, then by this marker.
|
|
234
|
+
5. If the harness opens a PR, embed the hidden session marker (`asrt get-link`) in the PR body — same requirement as interactive mode. The bot matches the PR to the session by branch name first, then by this marker.
|
|
241
235
|
|
|
242
236
|
Do not mix `--json` output with human-rendered output in the same run. Pick one mode and stick with it.
|
|
@@ -10,12 +10,15 @@ from models import MetadataPayload
|
|
|
10
10
|
|
|
11
11
|
def test_build_bundle_writes_session_metadata() -> None:
|
|
12
12
|
metadata = MetadataPayload(
|
|
13
|
-
session_id="session-1",
|
|
13
|
+
session_id="session-1",
|
|
14
|
+
stack_id="stack-1",
|
|
15
|
+
base_sha="base123",
|
|
16
|
+
clone_sha="clone456",
|
|
14
17
|
)
|
|
15
18
|
bundle_bytes = build_bundle(
|
|
16
19
|
metadata=metadata,
|
|
17
20
|
diff_text="diff --git a/file b/file",
|
|
18
|
-
|
|
21
|
+
review_diff_text="review diff",
|
|
19
22
|
prompts_text='{"ts": "2026-05-28T14:32:01Z", "text": "Do the thing"}\n',
|
|
20
23
|
checkpoint_text="Checkpoint text",
|
|
21
24
|
)
|
|
@@ -24,11 +27,9 @@ def test_build_bundle_writes_session_metadata() -> None:
|
|
|
24
27
|
meta_json = json.loads(zf.read(".assertion/metadata.json").decode("utf-8"))
|
|
25
28
|
assert meta_json["session_id"] == "session-1"
|
|
26
29
|
assert meta_json["stack_id"] == "stack-1"
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
assert meta_json["head_sha"] == "abc123"
|
|
31
|
-
assert "base_sha" not in meta_json
|
|
30
|
+
# clone_sha is the remote checkout anchor, so it ships as head_sha.
|
|
31
|
+
assert meta_json["head_sha"] == "clone456"
|
|
32
|
+
assert meta_json["base_sha"] == "base123"
|
|
32
33
|
|
|
33
34
|
prompts_raw = zf.read(".assertion/prompts").decode("utf-8")
|
|
34
35
|
assert '"text": "Do the thing"' in prompts_raw
|
|
@@ -37,5 +38,5 @@ def test_build_bundle_writes_session_metadata() -> None:
|
|
|
37
38
|
git_diff = zf.read("git.diff").decode("utf-8")
|
|
38
39
|
assert "diff --git a/file b/file" in git_diff
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
assert "diff
|
|
41
|
+
review_diff = zf.read("review.diff").decode("utf-8")
|
|
42
|
+
assert "review diff" in review_diff
|
|
@@ -2,15 +2,17 @@ import subprocess
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
|
-
|
|
5
|
+
import typer
|
|
6
6
|
|
|
7
7
|
from git import (
|
|
8
8
|
find_git_root,
|
|
9
9
|
get_full_diff,
|
|
10
10
|
get_head_sha,
|
|
11
11
|
get_merge_base,
|
|
12
|
+
get_remote_ancestor,
|
|
12
13
|
get_uncommitted_diff,
|
|
13
14
|
get_upstream_ref,
|
|
15
|
+
is_ancestor,
|
|
14
16
|
run_git_command,
|
|
15
17
|
)
|
|
16
18
|
|
|
@@ -41,7 +43,7 @@ def test_find_git_root_in_repo(tmp_path: Path) -> None:
|
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def test_find_git_root_outside_repo(tmp_path: Path) -> None:
|
|
44
|
-
with pytest.raises(
|
|
46
|
+
with pytest.raises(typer.Exit):
|
|
45
47
|
find_git_root(tmp_path)
|
|
46
48
|
|
|
47
49
|
|
|
@@ -656,3 +658,134 @@ def test_get_uncommitted_diff_preserves_trailing_blank_context_lines(
|
|
|
656
658
|
check=False,
|
|
657
659
|
)
|
|
658
660
|
assert apply_check.returncode == 0, apply_check.stderr
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
# --- is_ancestor ---
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def test_is_ancestor_true_for_parent(tmp_path: Path) -> None:
|
|
667
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
668
|
+
subprocess.run(
|
|
669
|
+
["git", "commit", "--allow-empty", "-m", "first"],
|
|
670
|
+
cwd=tmp_path,
|
|
671
|
+
capture_output=True,
|
|
672
|
+
check=True,
|
|
673
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
674
|
+
)
|
|
675
|
+
parent = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
676
|
+
subprocess.run(
|
|
677
|
+
["git", "commit", "--allow-empty", "-m", "second"],
|
|
678
|
+
cwd=tmp_path,
|
|
679
|
+
capture_output=True,
|
|
680
|
+
check=True,
|
|
681
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
682
|
+
)
|
|
683
|
+
head = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
684
|
+
|
|
685
|
+
assert is_ancestor(tmp_path, parent, head) is True
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def test_is_ancestor_false_for_sibling(tmp_path: Path) -> None:
|
|
689
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
690
|
+
subprocess.run(
|
|
691
|
+
["git", "commit", "--allow-empty", "-m", "first"],
|
|
692
|
+
cwd=tmp_path,
|
|
693
|
+
capture_output=True,
|
|
694
|
+
check=True,
|
|
695
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
696
|
+
)
|
|
697
|
+
parent = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
698
|
+
subprocess.run(
|
|
699
|
+
["git", "commit", "--allow-empty", "-m", "second"],
|
|
700
|
+
cwd=tmp_path,
|
|
701
|
+
capture_output=True,
|
|
702
|
+
check=True,
|
|
703
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
704
|
+
)
|
|
705
|
+
sibling = subprocess.run(
|
|
706
|
+
["git", "commit-tree", "HEAD^{tree}", "-p", parent, "-m", "sibling"],
|
|
707
|
+
cwd=tmp_path,
|
|
708
|
+
capture_output=True,
|
|
709
|
+
text=True,
|
|
710
|
+
check=True,
|
|
711
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
712
|
+
).stdout.strip()
|
|
713
|
+
|
|
714
|
+
assert is_ancestor(tmp_path, sibling, "HEAD") is False
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
# --- get_remote_ancestor ---
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def test_get_remote_ancestor_returns_sha_when_on_remote(tmp_path: Path) -> None:
|
|
721
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
722
|
+
subprocess.run(
|
|
723
|
+
["git", "commit", "--allow-empty", "-m", "first"],
|
|
724
|
+
cwd=tmp_path,
|
|
725
|
+
capture_output=True,
|
|
726
|
+
check=True,
|
|
727
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
728
|
+
)
|
|
729
|
+
sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
730
|
+
subprocess.run(
|
|
731
|
+
["git", "remote", "add", "origin", "http://example.com/repo.git"],
|
|
732
|
+
cwd=tmp_path,
|
|
733
|
+
capture_output=True,
|
|
734
|
+
check=True,
|
|
735
|
+
)
|
|
736
|
+
subprocess.run(
|
|
737
|
+
["git", "update-ref", "refs/remotes/origin/main", sha],
|
|
738
|
+
cwd=tmp_path,
|
|
739
|
+
capture_output=True,
|
|
740
|
+
check=True,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
assert get_remote_ancestor(tmp_path, sha) == sha
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def test_get_remote_ancestor_returns_nearest_remote_ancestor(tmp_path: Path) -> None:
|
|
747
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
748
|
+
subprocess.run(
|
|
749
|
+
["git", "commit", "--allow-empty", "-m", "remote"],
|
|
750
|
+
cwd=tmp_path,
|
|
751
|
+
capture_output=True,
|
|
752
|
+
check=True,
|
|
753
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
754
|
+
)
|
|
755
|
+
remote_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
756
|
+
subprocess.run(
|
|
757
|
+
["git", "commit", "--allow-empty", "-m", "local"],
|
|
758
|
+
cwd=tmp_path,
|
|
759
|
+
capture_output=True,
|
|
760
|
+
check=True,
|
|
761
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
762
|
+
)
|
|
763
|
+
local_sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
764
|
+
subprocess.run(
|
|
765
|
+
["git", "remote", "add", "origin", "http://example.com/repo.git"],
|
|
766
|
+
cwd=tmp_path,
|
|
767
|
+
capture_output=True,
|
|
768
|
+
check=True,
|
|
769
|
+
)
|
|
770
|
+
subprocess.run(
|
|
771
|
+
["git", "update-ref", "refs/remotes/origin/main", remote_sha],
|
|
772
|
+
cwd=tmp_path,
|
|
773
|
+
capture_output=True,
|
|
774
|
+
check=True,
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
assert get_remote_ancestor(tmp_path, local_sha) == remote_sha
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def test_get_remote_ancestor_returns_none_without_remote(tmp_path: Path) -> None:
|
|
781
|
+
subprocess.run(["git", "init"], cwd=tmp_path, capture_output=True, check=True)
|
|
782
|
+
subprocess.run(
|
|
783
|
+
["git", "commit", "--allow-empty", "-m", "first"],
|
|
784
|
+
cwd=tmp_path,
|
|
785
|
+
capture_output=True,
|
|
786
|
+
check=True,
|
|
787
|
+
env={**GIT_ENV, "HOME": str(tmp_path)},
|
|
788
|
+
)
|
|
789
|
+
sha = run_git_command(tmp_path, ["rev-parse", "HEAD"])
|
|
790
|
+
|
|
791
|
+
assert get_remote_ancestor(tmp_path, sha) is None
|
|
@@ -131,11 +131,8 @@ def test_install_records_skill_cleanup_guidance(tmp_path: Path, monkeypatch) ->
|
|
|
131
131
|
|
|
132
132
|
skill = (tmp_path / ".agents" / "skills" / "assertion-cli" / "SKILL.md").read_text()
|
|
133
133
|
assert "First command sequence for a new work session" in skill
|
|
134
|
-
assert
|
|
135
|
-
|
|
136
|
-
'and start fresh?"'
|
|
137
|
-
) in skill
|
|
138
|
-
assert "Do not run `asrt new` without explicit user consent" in skill
|
|
134
|
+
assert "`asrt session start`" in skill
|
|
135
|
+
assert "`asrt new`" not in skill
|
|
139
136
|
|
|
140
137
|
|
|
141
138
|
def test_install_requires_git_repo(tmp_path: Path, monkeypatch) -> None:
|
|
@@ -26,9 +26,10 @@ def test_update_metadata_anchor_preserves_session_id(tmp_path: Path) -> None:
|
|
|
26
26
|
path = tmp_path / "metadata.json"
|
|
27
27
|
original = MetadataPayload(session_id="session-1", stack_id="stack-1")
|
|
28
28
|
path.write_text(original.model_dump_json())
|
|
29
|
-
updated = update_metadata_anchor(path, original, "abc123")
|
|
29
|
+
updated = update_metadata_anchor(path, original, "abc123", clone_sha="def456")
|
|
30
30
|
assert updated.session_id == "session-1"
|
|
31
31
|
assert updated.base_sha == "abc123"
|
|
32
|
+
assert updated.clone_sha == "def456"
|
|
32
33
|
|
|
33
34
|
|
|
34
35
|
def test_update_metadata_anchor_preserves_extra_fields(tmp_path: Path) -> None:
|
|
@@ -45,14 +46,17 @@ def test_update_metadata_anchor_preserves_extra_fields(tmp_path: Path) -> None:
|
|
|
45
46
|
original = _load_metadata(path)
|
|
46
47
|
assert original is not None
|
|
47
48
|
|
|
48
|
-
updated = update_metadata_anchor(path, original, "abc123")
|
|
49
|
+
updated = update_metadata_anchor(path, original, "abc123", clone_sha="def456")
|
|
49
50
|
saved = json.loads(path.read_text())
|
|
50
51
|
|
|
51
|
-
# Local on-disk schema uses `base_sha
|
|
52
|
-
# `
|
|
52
|
+
# Local on-disk schema uses `base_sha` for the verification base and
|
|
53
|
+
# `clone_sha` for the remote checkout anchor. The wire-format remap of
|
|
54
|
+
# `clone_sha` to `head_sha` happens only at bundle-build time.
|
|
53
55
|
assert updated.base_sha == "abc123"
|
|
56
|
+
assert updated.clone_sha == "def456"
|
|
54
57
|
assert saved["prompt"] == "Keep this value"
|
|
55
58
|
assert saved["base_sha"] == "abc123"
|
|
59
|
+
assert saved["clone_sha"] == "def456"
|
|
56
60
|
assert "head_sha" not in saved
|
|
57
61
|
|
|
58
62
|
|
|
@@ -155,7 +159,7 @@ def test_checkpoint_rejects_stack_flag_when_session_exists(
|
|
|
155
159
|
session_id="existing-session", stack_id="existing-stack"
|
|
156
160
|
).model_dump_json()
|
|
157
161
|
)
|
|
158
|
-
(assertion_dir / "
|
|
162
|
+
(assertion_dir / "verify_base_sha").write_text("a" * 40 + "\n")
|
|
159
163
|
(assertion_dir / "prompts").write_text(
|
|
160
164
|
'{"ts": "2026-05-30T00:00:00Z", "text": "do the thing"}\n'
|
|
161
165
|
)
|
|
@@ -66,7 +66,7 @@ def test_session_start_does_not_write_skill_or_agent_files(
|
|
|
66
66
|
assert not (tmp_path / "AGENTS.md").exists()
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def
|
|
69
|
+
def test_session_start_resets_assertion_state_but_keeps_seed_trackable(
|
|
70
70
|
tmp_path: Path, monkeypatch
|
|
71
71
|
) -> None:
|
|
72
72
|
_init_repo(tmp_path)
|
|
@@ -75,7 +75,8 @@ def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
|
|
|
75
75
|
existing_gitignore = "*.pyc\n"
|
|
76
76
|
(tmp_path / ".gitignore").write_text(existing_gitignore)
|
|
77
77
|
(tmp_path / ".assertion").mkdir()
|
|
78
|
-
(tmp_path / ".assertion" / "
|
|
78
|
+
(tmp_path / ".assertion" / "verify_base_sha").write_text("local state\n")
|
|
79
|
+
(tmp_path / ".assertion" / "old_file").write_text("old\n")
|
|
79
80
|
(tmp_path / ".assertion" / "seed.py").write_text(
|
|
80
81
|
"from assertion_seed import seed\n"
|
|
81
82
|
)
|
|
@@ -87,8 +88,13 @@ def test_session_start_ignores_assertion_state_but_keeps_seed_trackable(
|
|
|
87
88
|
"*.pyc\n\n# Assertion CLI local state\n.assertion/*\n!.assertion/seed.py\n"
|
|
88
89
|
)
|
|
89
90
|
|
|
91
|
+
# Old state is cleared; seed.py is preserved.
|
|
92
|
+
assert not (tmp_path / ".assertion" / "old_file").exists()
|
|
93
|
+
assert (tmp_path / ".assertion" / "seed.py").exists()
|
|
94
|
+
assert (tmp_path / ".assertion" / "verify_base_sha").exists()
|
|
95
|
+
|
|
90
96
|
ignored_state = subprocess.run(
|
|
91
|
-
["git", "check-ignore", ".assertion/
|
|
97
|
+
["git", "check-ignore", ".assertion/verify_base_sha"],
|
|
92
98
|
cwd=tmp_path,
|
|
93
99
|
capture_output=True,
|
|
94
100
|
)
|
|
@@ -214,14 +220,13 @@ def test_session_start_falls_back_to_head_when_no_upstream(
|
|
|
214
220
|
|
|
215
221
|
assert result.exit_code == 0
|
|
216
222
|
assert "falling back to HEAD" in result.stderr
|
|
217
|
-
assert (tmp_path / ".assertion" / "
|
|
223
|
+
assert (tmp_path / ".assertion" / "verify_base_sha").read_text().strip() == head_sha
|
|
218
224
|
|
|
219
225
|
|
|
220
|
-
def
|
|
221
|
-
"""`asrt session start` pins the
|
|
226
|
+
def test_session_start_records_merge_base_as_verify_base(tmp_path: Path, monkeypatch) -> None:
|
|
227
|
+
"""`asrt session start` pins the verification base to the branch merge-base.
|
|
222
228
|
|
|
223
|
-
Every subsequent checkpoint/verify diffs against this SHA
|
|
224
|
-
check out the upstream-visible base before applying the full diff.
|
|
229
|
+
Every subsequent checkpoint/verify diffs against this SHA.
|
|
225
230
|
"""
|
|
226
231
|
_init_repo(tmp_path)
|
|
227
232
|
monkeypatch.chdir(tmp_path)
|
|
@@ -237,7 +242,7 @@ def test_session_start_records_merge_base_as_base(tmp_path: Path, monkeypatch) -
|
|
|
237
242
|
merge_base = _run_git(tmp_path, "merge-base", "HEAD", upstream)
|
|
238
243
|
head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
239
244
|
|
|
240
|
-
base_sha_path = tmp_path / ".assertion" / "
|
|
245
|
+
base_sha_path = tmp_path / ".assertion" / "verify_base_sha"
|
|
241
246
|
assert base_sha_path.exists()
|
|
242
247
|
assert base_sha_path.read_text().strip() == merge_base
|
|
243
248
|
assert merge_base != head_sha
|
|
@@ -252,4 +257,33 @@ def test_session_start_errors_when_repo_has_no_commits(
|
|
|
252
257
|
|
|
253
258
|
result = runner.invoke(main.app, ["session", "start"])
|
|
254
259
|
assert result.exit_code == 1
|
|
255
|
-
assert not (tmp_path / ".assertion" / "
|
|
260
|
+
assert not (tmp_path / ".assertion" / "verify_base_sha").exists()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_session_start_from_explicit_ref(tmp_path: Path, monkeypatch) -> None:
|
|
264
|
+
"""`--from` lets the user choose the verification base explicitly."""
|
|
265
|
+
_init_repo(tmp_path)
|
|
266
|
+
monkeypatch.chdir(tmp_path)
|
|
267
|
+
verify_base = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
268
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "local work")
|
|
269
|
+
|
|
270
|
+
result = runner.invoke(main.app, ["session", "start", "--from", verify_base])
|
|
271
|
+
assert result.exit_code == 0, result.stderr
|
|
272
|
+
|
|
273
|
+
assert (
|
|
274
|
+
tmp_path / ".assertion" / "verify_base_sha"
|
|
275
|
+
).read_text().strip() == verify_base
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_session_start_from_ref_rejects_non_ancestor(
|
|
279
|
+
tmp_path: Path, monkeypatch
|
|
280
|
+
) -> None:
|
|
281
|
+
"""`--from` must point to an ancestor of HEAD."""
|
|
282
|
+
_init_repo(tmp_path)
|
|
283
|
+
monkeypatch.chdir(tmp_path)
|
|
284
|
+
_run_git(tmp_path, "commit", "--allow-empty", "-m", "local work")
|
|
285
|
+
orphan = _run_git(tmp_path, "commit-tree", "HEAD^{tree}", "-m", "orphan")
|
|
286
|
+
|
|
287
|
+
result = runner.invoke(main.app, ["session", "start", "--from", orphan])
|
|
288
|
+
assert result.exit_code == 1
|
|
289
|
+
assert "not an ancestor of HEAD" in result.stderr
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# Assertion verification
|
|
2
|
-
|
|
3
|
-
This repo uses the Assertion CLI (`asrt`) to track user intent and verify your work. The full workflow lives in the `assertion-cli` skill:
|
|
4
|
-
|
|
5
|
-
- **Claude Code** — [`.claude/skills/assertion-cli/SKILL.md`](.claude/skills/assertion-cli/SKILL.md)
|
|
6
|
-
- **Codex / Cursor** — [`.agents/skills/assertion-cli/SKILL.md`](.agents/skills/assertion-cli/SKILL.md)
|
|
7
|
-
|
|
8
|
-
Both files are byte-identical — read whichever your agent loads (or either one directly). In brief, follow these three rules for every conversation:
|
|
9
|
-
|
|
10
|
-
1. **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.
|
|
@@ -1,142 +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
|
-
_GIT_COMMIT_ENV = {
|
|
14
|
-
"GIT_AUTHOR_NAME": "T",
|
|
15
|
-
"GIT_AUTHOR_EMAIL": "t@t",
|
|
16
|
-
"GIT_COMMITTER_NAME": "T",
|
|
17
|
-
"GIT_COMMITTER_EMAIL": "t@t",
|
|
18
|
-
"PATH": "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin",
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def _run_git(repo: Path, *args: str) -> str:
|
|
23
|
-
result = subprocess.run(
|
|
24
|
-
["git", *args],
|
|
25
|
-
cwd=repo,
|
|
26
|
-
capture_output=True,
|
|
27
|
-
check=True,
|
|
28
|
-
env={**_GIT_COMMIT_ENV, "HOME": str(repo)},
|
|
29
|
-
text=True,
|
|
30
|
-
)
|
|
31
|
-
return result.stdout.strip()
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _init_repo(tmp_path: Path) -> str:
|
|
35
|
-
_run_git(tmp_path, "init")
|
|
36
|
-
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
37
|
-
branch = _run_git(tmp_path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
38
|
-
_run_git(tmp_path, "remote", "add", "origin", "http://example.com/repo.git")
|
|
39
|
-
_run_git(tmp_path, "update-ref", f"refs/remotes/origin/{branch}", "HEAD")
|
|
40
|
-
_run_git(tmp_path, "branch", "-u", f"origin/{branch}")
|
|
41
|
-
return _run_git(tmp_path, "merge-base", "HEAD", f"origin/{branch}")
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_new_clears_run_state_and_records_merge_base(
|
|
45
|
-
tmp_path: Path,
|
|
46
|
-
monkeypatch,
|
|
47
|
-
) -> None:
|
|
48
|
-
merge_base = _init_repo(tmp_path)
|
|
49
|
-
monkeypatch.chdir(tmp_path)
|
|
50
|
-
|
|
51
|
-
assertion_dir = tmp_path / ".assertion"
|
|
52
|
-
(assertion_dir / "screenshots" / "verification-1").mkdir(parents=True)
|
|
53
|
-
(assertion_dir / "metadata.json").write_text("{}")
|
|
54
|
-
(assertion_dir / "prompts").write_text("old prompt")
|
|
55
|
-
(assertion_dir / "checkpoint").write_text("old checkpoint")
|
|
56
|
-
(assertion_dir / "link").write_text("old link")
|
|
57
|
-
(assertion_dir / "verify_session_id").write_text("old verification")
|
|
58
|
-
(assertion_dir / "base_sha").write_text("old-base")
|
|
59
|
-
(
|
|
60
|
-
assertion_dir / "screenshots" / "verification-1" / "screenshot_000.png"
|
|
61
|
-
).write_bytes(b"png")
|
|
62
|
-
|
|
63
|
-
result = runner.invoke(main.app, ["new"])
|
|
64
|
-
|
|
65
|
-
assert result.exit_code == 0, result.stderr
|
|
66
|
-
assert (assertion_dir / "base_sha").read_text() == merge_base + "\n"
|
|
67
|
-
assert not (assertion_dir / "metadata.json").exists()
|
|
68
|
-
assert not (assertion_dir / "prompts").exists()
|
|
69
|
-
assert not (assertion_dir / "checkpoint").exists()
|
|
70
|
-
assert not (assertion_dir / "link").exists()
|
|
71
|
-
assert not (assertion_dir / "verify_session_id").exists()
|
|
72
|
-
assert not (assertion_dir / "screenshots").exists()
|
|
73
|
-
assert "Recorded diff base" in result.stdout
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_new_succeeds_when_assertion_dir_missing(
|
|
77
|
-
tmp_path: Path,
|
|
78
|
-
monkeypatch,
|
|
79
|
-
) -> None:
|
|
80
|
-
merge_base = _init_repo(tmp_path)
|
|
81
|
-
monkeypatch.chdir(tmp_path)
|
|
82
|
-
|
|
83
|
-
result = runner.invoke(main.app, ["new"])
|
|
84
|
-
|
|
85
|
-
assert result.exit_code == 0, result.stderr
|
|
86
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def test_new_keeps_merge_base_after_local_head_changes(
|
|
90
|
-
tmp_path: Path,
|
|
91
|
-
monkeypatch,
|
|
92
|
-
) -> None:
|
|
93
|
-
merge_base = _init_repo(tmp_path)
|
|
94
|
-
monkeypatch.chdir(tmp_path)
|
|
95
|
-
|
|
96
|
-
first_result = runner.invoke(main.app, ["new"])
|
|
97
|
-
assert first_result.exit_code == 0, first_result.stderr
|
|
98
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
99
|
-
|
|
100
|
-
_run_git(tmp_path, "commit", "--allow-empty", "-m", "second")
|
|
101
|
-
second_head = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
102
|
-
|
|
103
|
-
second_result = runner.invoke(main.app, ["new"])
|
|
104
|
-
|
|
105
|
-
assert second_result.exit_code == 0, second_result.stderr
|
|
106
|
-
assert second_head != merge_base
|
|
107
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text() == merge_base + "\n"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def test_new_leaves_next_checkpoint_requiring_stack(
|
|
111
|
-
tmp_path: Path,
|
|
112
|
-
monkeypatch,
|
|
113
|
-
) -> None:
|
|
114
|
-
_init_repo(tmp_path)
|
|
115
|
-
monkeypatch.chdir(tmp_path)
|
|
116
|
-
|
|
117
|
-
assertion_dir = tmp_path / ".assertion"
|
|
118
|
-
assertion_dir.mkdir()
|
|
119
|
-
(assertion_dir / "metadata.json").write_text("{}")
|
|
120
|
-
(assertion_dir / "prompts").write_text("old prompt")
|
|
121
|
-
|
|
122
|
-
result = runner.invoke(main.app, ["new"])
|
|
123
|
-
|
|
124
|
-
assert result.exit_code == 0, result.stderr
|
|
125
|
-
assert not (assertion_dir / "metadata.json").exists()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def test_new_falls_back_to_head_when_no_upstream(
|
|
129
|
-
tmp_path: Path,
|
|
130
|
-
monkeypatch,
|
|
131
|
-
) -> None:
|
|
132
|
-
"""When no upstream tracking branch exists, fall back to HEAD instead of erroring."""
|
|
133
|
-
_run_git(tmp_path, "init")
|
|
134
|
-
_run_git(tmp_path, "commit", "--allow-empty", "-m", "init")
|
|
135
|
-
head_sha = _run_git(tmp_path, "rev-parse", "HEAD")
|
|
136
|
-
monkeypatch.chdir(tmp_path)
|
|
137
|
-
|
|
138
|
-
result = runner.invoke(main.app, ["new"])
|
|
139
|
-
|
|
140
|
-
assert result.exit_code == 0
|
|
141
|
-
assert "falling back to HEAD" in result.stderr
|
|
142
|
-
assert (tmp_path / ".assertion" / "base_sha").read_text().strip() == head_sha
|
|
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
|