Add fork, compare, restore CLI commands and fix compare correctness

Round 2 of the agent handoff dogfood showed that every multi-branch
operation required reaching past the CLI into curl: fork had no CLI
command, `smriti checkpoint create` always spawned a fresh session with
no way to attach to a forked one, and the compare endpoint returned
useless output (common_ancestor_commit_id was missing from the response,
and shared-set matching was exact-string so two agents phrasing the
same commitment differently showed zero overlap).

This ships the full CLI surface for multi-branch workflows plus the
backend fixes that make compare actually useful:

  smriti fork <checkpoint-id> [--branch <name>]
  smriti restore <checkpoint-id>
  smriti compare <checkpoint-a> <checkpoint-b>
  smriti checkpoint create <space> --session <session-id>

The compare endpoint now walks parent chains to compute a lowest
common ancestor (bounded to 1000 steps with a cycle guard) and returns
it on CheckpointDiff as an optional uuid. Shared-set matching uses a
lightweight lowercase + punctuation-strip + whitespace-collapse
normalization for keying, but returns the original A-side strings so
the output stays readable. Four new compare tests cover direct and
two-step LCA, null LCA for unrelated checkpoints, and normalized
shared-set matching. Existing compare tests still pass unchanged
because their data ("Use Redis" vs "Use Postgres") is distinct at any
sensible normalization level.

`smriti restore <checkpoint>` is a pure read — it renders any
checkpoint as a continuation brief matching `smriti state <space>`
shape. `smriti fork` derives the space from the checkpoint so the
user does not have to pass it separately. `--session` on checkpoint
create is purely additive: when absent, the existing auto-session
behavior is unchanged.

147/147 backend tests pass (143 pre-existing + 4 new).
This commit is contained in:
Himanshu Dongre 2026-04-11 17:50:03 +05:30
parent 73c71b4c9d
commit 2a6614bd80
6 changed files with 507 additions and 15 deletions

View file

@ -13,6 +13,7 @@ Checkpoint comparison:
from __future__ import annotations from __future__ import annotations
import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
@ -128,6 +129,7 @@ class CheckpointDiff(BaseModel):
tasks_only_a: list[str] tasks_only_a: list[str]
tasks_only_b: list[str] tasks_only_b: list[str]
tasks_shared: list[str] tasks_shared: list[str]
common_ancestor_commit_id: Optional[uuid.UUID] = None
class CompareResponse(BaseModel): class CompareResponse(BaseModel):
@ -174,15 +176,108 @@ def _extract_text(item) -> str:
return str(item) return str(item)
def _normalize_text(text: str) -> str:
"""Lightweight normalization for compare shared-set matching.
Lowercase, strip non-alphanumeric characters (keep spaces), collapse
whitespace. Catches case and punctuation differences. This is not
semantic equality two items that mean the same thing but share
no tokens ("use stdlib only" vs "no third-party runtime deps") will
still look different. Semantic match is a later build.
"""
lowered = text.lower()
stripped = re.sub(r"[^a-z0-9 ]", "", lowered)
return " ".join(stripped.split())
def _diff_lists(a: list, b: list) -> tuple[list[str], list[str], list[str]]: def _diff_lists(a: list, b: list) -> tuple[list[str], list[str], list[str]]:
"""Return (only_in_a, only_in_b, in_both) comparing by normalised text.""" """Return (only_in_a, only_in_b, in_both) using normalized keys for
set_a = {_extract_text(x) for x in a} matching but returning the original strings.
set_b = {_extract_text(x) for x in b}
return ( Matching uses _normalize_text as the equivalence key. When two items
sorted(set_a - set_b), normalize to the same key, the A-side original wins (deterministic).
sorted(set_b - set_a), Items whose normalized form is empty (e.g. "---", "..." or whitespace
sorted(set_a & set_b), only) are treated as unmatchable and fall into only_a / only_b based
) on their origin, never into shared.
"""
a_items = [_extract_text(x) for x in a]
b_items = [_extract_text(x) for x in b]
a_norm: dict[str, str] = {}
a_unmatchable: list[str] = []
for item in a_items:
key = _normalize_text(item)
if not key:
a_unmatchable.append(item)
elif key not in a_norm:
a_norm[key] = item
b_norm: dict[str, str] = {}
b_unmatchable: list[str] = []
for item in b_items:
key = _normalize_text(item)
if not key:
b_unmatchable.append(item)
elif key not in b_norm:
b_norm[key] = item
a_keys = set(a_norm.keys())
b_keys = set(b_norm.keys())
only_a = sorted([a_norm[k] for k in (a_keys - b_keys)] + a_unmatchable)
only_b = sorted([b_norm[k] for k in (b_keys - a_keys)] + b_unmatchable)
shared = sorted(a_norm[k] for k in (a_keys & b_keys))
return only_a, only_b, shared
def _find_common_ancestor(
a_id: uuid.UUID,
b_id: uuid.UUID,
db: Session,
max_depth: int = 1000,
) -> Optional[uuid.UUID]:
"""Find the lowest common ancestor of two checkpoints by walking
parent_commit_id chains.
Walk A's chain upward, collecting every ancestor id into a set, then
walk B's chain upward and return the first id that appears in A's set.
Returns None if the two checkpoints share no ancestor within max_depth.
max_depth bounds both walks so a corrupt parent cycle can't hang the
endpoint. Smriti histories are shallow in practice (dozens of commits
for a long-running project, not thousands), so 1000 is generous.
When a_id == b_id, the checkpoint is its own LCA returns a_id.
"""
a_ancestors: set[uuid.UUID] = set()
current: Optional[uuid.UUID] = a_id
for _ in range(max_depth):
if current is None:
break
if current in a_ancestors:
break # cycle guard
a_ancestors.add(current)
commit = db.get(CommitModel, current)
if commit is None:
break
current = commit.parent_commit_id
current = b_id
seen_on_b: set[uuid.UUID] = set()
for _ in range(max_depth):
if current is None:
return None
if current in a_ancestors:
return current
if current in seen_on_b:
return None # cycle guard
seen_on_b.add(current)
commit = db.get(CommitModel, current)
if commit is None:
return None
current = commit.parent_commit_id
return None
# ── Fork endpoint ───────────────────────────────────────────────────────────── # ── Fork endpoint ─────────────────────────────────────────────────────────────
@ -305,6 +400,8 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends(
commit_a = _get_commit(a_id, db) commit_a = _get_commit(a_id, db)
commit_b = _get_commit(b_id, db) commit_b = _get_commit(b_id, db)
lca = _find_common_ancestor(commit_a.id, commit_b.id, db)
dec_only_a, dec_only_b, dec_shared = _diff_lists( dec_only_a, dec_only_b, dec_shared = _diff_lists(
commit_a.decisions or [], commit_b.decisions or [] commit_a.decisions or [], commit_b.decisions or []
) )
@ -347,6 +444,7 @@ def compare_checkpoints(a_id: uuid.UUID, b_id: uuid.UUID, db: Session = Depends(
tasks_only_a=task_only_a, tasks_only_a=task_only_a,
tasks_only_b=task_only_b, tasks_only_b=task_only_b,
tasks_shared=task_shared, tasks_shared=task_shared,
common_ancestor_commit_id=lca,
), ),
) )

View file

@ -374,6 +374,104 @@ def test_compare_nonexistent_checkpoint(client):
assert r.status_code == 404, r.text assert r.status_code == 404, r.text
# ── Compare: common ancestor walk + normalized shared-set matching ────────────
def test_compare_finds_direct_common_ancestor(client):
"""LCA is computed when two checkpoints share a direct parent.
C1 on main. C2 on main (parent=C1). Fork from C1, commit C3 on the
fork (parent=C1). Compare C2 vs C3 -> LCA is C1.
"""
repo_id = _create_repo(client, "LCA Direct")
session_id = _create_session(client, repo_id)
c1 = _commit(client, repo_id, session_id, "root")
_send(client, session_id, "turn 1")
c2 = _commit(client, repo_id, session_id, "c2 on main")
fork = _fork(client, repo_id, c1["id"], branch_name="branch-a").json()
fork_session_id = fork["session_id"]
_send(client, fork_session_id, "turn on fork")
c3 = _commit(client, repo_id, fork_session_id, "c3 on fork")
r = client.get(f"/api/v5/lineage/checkpoints/{c2['id']}/compare/{c3['id']}")
assert r.status_code == 200, r.text
diff = r.json()["diff"]
assert diff["common_ancestor_commit_id"] == c1["id"]
def test_compare_finds_two_step_ancestor(client):
"""LCA walks multiple parent steps up both chains.
C1 -> C2 -> C3 on main. Fork from C2, commit C4 on fork.
Compare C3 vs C4 -> LCA is C2 (two steps up from C3, one step from C4).
"""
repo_id = _create_repo(client, "LCA Two Step")
session_id = _create_session(client, repo_id)
_commit(client, repo_id, session_id, "c1")
_send(client, session_id, "turn a")
c2 = _commit(client, repo_id, session_id, "c2")
_send(client, session_id, "turn b")
c3 = _commit(client, repo_id, session_id, "c3")
fork = _fork(client, repo_id, c2["id"], branch_name="branch-two").json()
fork_session_id = fork["session_id"]
_send(client, fork_session_id, "turn on fork")
c4 = _commit(client, repo_id, fork_session_id, "c4 on fork")
r = client.get(f"/api/v5/lineage/checkpoints/{c3['id']}/compare/{c4['id']}")
assert r.status_code == 200, r.text
diff = r.json()["diff"]
assert diff["common_ancestor_commit_id"] == c2["id"]
def test_compare_returns_null_ancestor_for_unrelated_checkpoints(client):
"""Checkpoints in separate spaces share no ancestor -> LCA is None."""
repo_a = _create_repo(client, "LCA Unrelated A")
session_a = _create_session(client, repo_a)
c_a = _commit(client, repo_a, session_a, "isolated a")
repo_b = _create_repo(client, "LCA Unrelated B")
session_b = _create_session(client, repo_b)
c_b = _commit(client, repo_b, session_b, "isolated b")
r = client.get(f"/api/v5/lineage/checkpoints/{c_a['id']}/compare/{c_b['id']}")
assert r.status_code == 200, r.text
diff = r.json()["diff"]
assert diff["common_ancestor_commit_id"] is None
def test_compare_shared_set_matches_normalized(client):
"""Shared-set matching ignores case + punctuation differences.
Two agents agreeing on the same commitment with slightly different
wording should show the overlap in shared. The A-side original is
the deterministic winner for the displayed shared value.
"""
repo_id = _create_repo(client, "Shared Normalized")
session_id = _create_session(client, repo_id)
c_a = _commit(client, repo_id, session_id, "a",
decisions=["Use stdlib only", "Prefer PostgreSQL", "Ship a CLI"])
fork = _fork(client, repo_id, c_a["id"], branch_name="variant").json()
fork_session_id = fork["session_id"]
_send(client, fork_session_id, "turn")
c_b = _commit(client, repo_id, fork_session_id, "b",
decisions=["use STDLIB only.", "Prefer postgresql!", "Skip the CLI"])
r = client.get(f"/api/v5/lineage/checkpoints/{c_a['id']}/compare/{c_b['id']}")
assert r.status_code == 200, r.text
diff = r.json()["diff"]
# The two stdlib / postgresql commitments normalize equally and show
# up in shared with the A-side originals.
assert "Use stdlib only" in diff["decisions_shared"]
assert "Prefer PostgreSQL" in diff["decisions_shared"]
# "Ship a CLI" and "Skip the CLI" normalize differently, so they split.
assert "Ship a CLI" in diff["decisions_only_a"]
assert "Skip the CLI" in diff["decisions_only_b"]
# ── Regression: existing sessions default to main branch ────────────────────── # ── Regression: existing sessions default to main branch ──────────────────────
def test_existing_session_defaults_to_main_branch(client): def test_existing_session_defaults_to_main_branch(client):

View file

@ -28,16 +28,23 @@ Or pass `--api-url` on any command.
``` ```
smriti space list smriti space list
smriti space create <name> [--description "..."] smriti space create <name> [--description "..."]
smriti space delete <space> [-y]
smriti state <space> # continuation brief smriti state <space> # continuation brief
smriti state <space> --full-artifacts # include full artifacts smriti state <space> --full-artifacts # include full artifacts
smriti state <space> --json # structured output smriti state <space> --json # structured output
smriti fork <checkpoint-id> [--branch <name>] # new session from checkpoint
smriti restore <checkpoint-id> # brief of a specific checkpoint
smriti compare <checkpoint-a> <checkpoint-b> # structured diff
smriti checkpoint create <space> # reads JSON from stdin smriti checkpoint create <space> # reads JSON from stdin
smriti checkpoint create <space> --from-json <path> # from file smriti checkpoint create <space> --from-json <path> # from file
smriti checkpoint create <space> --session <session-id> # attach to existing session
smriti checkpoint show <checkpoint-id> smriti checkpoint show <checkpoint-id>
smriti checkpoint list <space> smriti checkpoint list <space>
smriti checkpoint review <checkpoint-id> smriti checkpoint review <checkpoint-id>
smriti checkpoint delete <checkpoint-id> [--cascade] [-y]
``` ```
Every command supports `--json` for structured output. Every command supports `--json` for structured output.
@ -76,6 +83,32 @@ Review a specific checkpoint for consistency issues:
smriti checkpoint review <checkpoint-id> smriti checkpoint review <checkpoint-id>
``` ```
## Multi-branch workflow
When you want to explore an alternative direction from a checkpoint without losing the main branch, fork it into a new session and write checkpoints there:
```bash
# Fork a new session off checkpoint C1
smriti fork <C1-checkpoint-id> --branch experiment
# The output gives you the new session ID. Write a checkpoint to that session:
cat <<'JSON' | smriti checkpoint create my-project --session <fork-session-id>
{
"message": "Alternative design direction",
"summary": "...",
"decisions": ["Try stdlib only instead of click"]
}
JSON
# Compare the two branches
smriti compare <C1-checkpoint-id> <new-checkpoint-id>
# Read any checkpoint as a continuation brief (what you'd need to continue from it)
smriti restore <new-checkpoint-id>
```
`smriti compare` shows a structured diff with `Shared`, `Only in A`, and `Only in B` sections for decisions, assumptions, and tasks, plus the lowest common ancestor of the two checkpoints. The shared-set matching is case- and punctuation-insensitive, so two agents phrasing the same commitment differently still show up as shared.
## Checkpoint payload schema ## Checkpoint payload schema
Only `message` is required. Every other field defaults to empty. Only `message` is required. Every other field defaults to empty.

View file

@ -155,6 +155,37 @@ class SmritiClient:
def review_checkpoint(self, commit_id: str) -> dict: def review_checkpoint(self, commit_id: str) -> dict:
return self._request("POST", f"/api/v5/checkpoint/{commit_id}/review") return self._request("POST", f"/api/v5/checkpoint/{commit_id}/review")
def compare_checkpoints(self, checkpoint_a_id: str, checkpoint_b_id: str) -> dict:
"""GET /api/v5/lineage/checkpoints/{a}/compare/{b}
Returns the full CompareResponse dict with `checkpoint_a`,
`checkpoint_b`, and `diff` (including the new
`common_ancestor_commit_id` field and normalized shared sets).
"""
return self._request(
"GET",
f"/api/v5/lineage/checkpoints/{checkpoint_a_id}/compare/{checkpoint_b_id}",
)
def fork_session(
self,
space_id: str,
checkpoint_id: str,
branch_name: str = "",
) -> dict:
"""POST /api/v5/lineage/sessions/fork
Returns the ForkSessionResponse: session_id, branch_name,
forked_from_checkpoint_id, history_base_seq.
"""
payload: dict = {
"space_id": space_id,
"checkpoint_id": checkpoint_id,
}
if branch_name:
payload["branch_name"] = branch_name
return self._request("POST", "/api/v5/lineage/sessions/fork", json=payload)
def delete_commit(self, commit_id: str, cascade: bool = False) -> None: def delete_commit(self, commit_id: str, cascade: bool = False) -> None:
params = {"cascade": "true"} if cascade else None params = {"cascade": "true"} if cascade else None
self._request("DELETE", f"/api/v2/commits/{commit_id}", params=params) self._request("DELETE", f"/api/v2/commits/{commit_id}", params=params)

View file

@ -181,6 +181,136 @@ _REVIEW_ISSUE_LABELS = {
} }
def format_fork_result(fork: dict, source_commit: dict) -> str:
"""One-screen output for `smriti fork` confirming the new session and
giving the user the next command to run."""
src_hash = _short_hash(source_commit.get("commit_hash"))
src_message = source_commit.get("message", "").strip() or "(no message)"
space_id = source_commit.get("repo_id", "")
branch = fork.get("branch_name", "?")
session_id = fork.get("session_id", "?")
lines = [
f"Forked from `{src_hash}` — \"{src_message}\"",
f" → new session: {branch} ({session_id})",
f" → seeded from: {src_hash}",
"",
"Next: write a checkpoint to this session with",
f" smriti checkpoint create {space_id} --session {session_id} < checkpoint.json",
"",
]
return "\n".join(lines)
def format_restore_brief(
space: dict,
commit: dict,
*,
full_artifacts: bool = False,
) -> str:
"""Render a specific checkpoint as a continuation brief.
Shape mirrors `format_state_brief` so an agent reading the output
can continue work from this checkpoint just as if it were HEAD.
A header line disambiguates from `smriti state` output.
"""
head = {
"commit_id": commit.get("id"),
"commit_hash": commit.get("commit_hash"),
"summary": commit.get("summary", ""),
"objective": commit.get("objective", ""),
"latest_session_id": None,
"latest_session_title": None,
}
header = (
f"_Continuation brief for checkpoint "
f"`{_short_hash(commit.get('commit_hash'))}`_\n\n"
)
return header + format_state_brief(space, head, commit, full_artifacts=full_artifacts)
def format_compare_result(result: dict, *, full_artifacts: bool = False) -> str:
"""Render a checkpoint compare response as a readable markdown diff.
Sections elided when empty. full_artifacts is accepted for CLI
consistency but not currently used compare does not surface
artifacts in this build.
"""
a = result.get("checkpoint_a") or {}
b = result.get("checkpoint_b") or {}
diff = result.get("diff") or {}
a_hash = _short_hash(a.get("commit_hash"))
b_hash = _short_hash(b.get("commit_hash"))
a_branch = a.get("branch_name") or "main"
b_branch = b.get("branch_name") or "main"
parts: list[str] = []
parts.append(f"# Compare `{a_hash}` ↔ `{b_hash}`\n")
lca = diff.get("common_ancestor_commit_id")
if lca:
parts.append(f"Common ancestor: `{lca}`\n")
else:
parts.append("Common ancestor: _none — unrelated histories_\n")
parts.append(
f"- A: `{a_hash}` on `{a_branch}` — {a.get('message', '').strip() or '(no message)'}\n"
f"- B: `{b_hash}` on `{b_branch}` — {b.get('message', '').strip() or '(no message)'}\n"
)
# Summary / objective — show side-by-side when they differ
summary_a = (diff.get("summary_a") or "").strip()
summary_b = (diff.get("summary_b") or "").strip()
if summary_a or summary_b:
if summary_a == summary_b and summary_a:
parts.append(f"## Summary (identical)\n{summary_a}\n")
else:
if summary_a:
parts.append(f"## Summary (A)\n{summary_a}\n")
if summary_b:
parts.append(f"## Summary (B)\n{summary_b}\n")
objective_a = (diff.get("objective_a") or "").strip()
objective_b = (diff.get("objective_b") or "").strip()
if objective_a or objective_b:
if objective_a == objective_b and objective_a:
parts.append(f"## Objective (identical)\n{objective_a}\n")
else:
if objective_a:
parts.append(f"## Objective (A)\n{objective_a}\n")
if objective_b:
parts.append(f"## Objective (B)\n{objective_b}\n")
def _bullet_group(heading: str, items: list[str]) -> str:
if not items:
return ""
lines = [f"### {heading}"]
for item in items:
lines.append(f"- {item}")
return "\n".join(lines) + "\n"
def _field_section(label: str, shared_key: str, only_a_key: str, only_b_key: str) -> None:
shared = diff.get(shared_key) or []
only_a = diff.get(only_a_key) or []
only_b = diff.get(only_b_key) or []
if not (shared or only_a or only_b):
return
parts.append(f"## {label}\n")
group = ""
group += _bullet_group("Shared", shared)
group += _bullet_group(f"Only in A (`{a_hash}`)", only_a)
group += _bullet_group(f"Only in B (`{b_hash}`)", only_b)
parts.append(group)
_field_section("Decisions", "decisions_shared", "decisions_only_a", "decisions_only_b")
_field_section("Assumptions", "assumptions_shared", "assumptions_only_a", "assumptions_only_b")
_field_section("Tasks", "tasks_shared", "tasks_only_a", "tasks_only_b")
return "\n".join(p for p in parts if p).rstrip() + "\n"
def format_review(result: dict) -> str: def format_review(result: dict) -> str:
issues = result.get("issues") or [] issues = result.get("issues") or []
suggestions = result.get("suggestions") or [] suggestions = result.get("suggestions") or []

View file

@ -6,12 +6,21 @@ Commands for agent and programmatic use:
smriti space create <name> [--description] smriti space create <name> [--description]
smriti space delete <space> [-y] smriti space delete <space> [-y]
smriti state <space> smriti state <space>
smriti checkpoint create <space> # reads JSON from stdin smriti fork <checkpoint-id> [--branch <name>]
smriti restore <checkpoint-id>
smriti compare <checkpoint-a> <checkpoint-b>
smriti checkpoint create <space> [--session <id>] # reads JSON from stdin
smriti checkpoint show <checkpoint-id> smriti checkpoint show <checkpoint-id>
smriti checkpoint list <space> smriti checkpoint list <space>
smriti checkpoint review <checkpoint-id> smriti checkpoint review <checkpoint-id>
smriti checkpoint delete <checkpoint-id> [--cascade] [-y] smriti checkpoint delete <checkpoint-id> [--cascade] [-y]
Multi-branch workflow: use `smriti fork <checkpoint>` to start a new
session on a new branch, then `smriti checkpoint create <space> --session
<fork-session-id>` to write a checkpoint on that branch, then `smriti
compare <a> <b>` to see how the branches diverged, and `smriti restore
<checkpoint>` to read any checkpoint as a continuation brief.
Every command supports --json for structured output. Every command supports --json for structured output.
Default output is a readable markdown brief. Default output is a readable markdown brief.
@ -29,6 +38,9 @@ from .client import SmritiClient, SmritiError
from .formatters import ( from .formatters import (
format_checkpoint, format_checkpoint,
format_commit_list, format_commit_list,
format_compare_result,
format_fork_result,
format_restore_brief,
format_review, format_review,
format_space_list, format_space_list,
format_state_brief, format_state_brief,
@ -180,15 +192,22 @@ def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> Non
# The V4 commit endpoint requires a session_id. Agents typically do not # The V4 commit endpoint requires a session_id. Agents typically do not
# have one — the CLI creates a lightweight session on demand and attaches # have one — the CLI creates a lightweight session on demand and attaches
# the checkpoint to it. Agents should not care about the session. # the checkpoint to it. With --session <id>, attach the checkpoint to an
session = client.create_session( # existing session instead (used for fork workflows where the caller
repo_id=space["id"], # already ran `smriti fork` and wants to write a checkpoint on the new
title=f"cli: {payload['message'][:80]}", # branch).
) if args.session:
session_id = args.session
else:
session = client.create_session(
repo_id=space["id"],
title=f"cli: {payload['message'][:80]}",
)
session_id = session["id"]
commit_payload = { commit_payload = {
"repo_id": space["id"], "repo_id": space["id"],
"session_id": session["id"], "session_id": session_id,
"message": payload["message"], "message": payload["message"],
"summary": payload.get("summary", ""), "summary": payload.get("summary", ""),
"objective": payload.get("objective", ""), "objective": payload.get("objective", ""),
@ -270,6 +289,42 @@ def cmd_checkpoint_delete(client: SmritiClient, args: argparse.Namespace) -> Non
print(f"Deleted checkpoint `{commit['commit_hash'][:7]}`{note}.") print(f"Deleted checkpoint `{commit['commit_hash'][:7]}`{note}.")
def cmd_fork(client: SmritiClient, args: argparse.Namespace) -> None:
# Fetch the checkpoint first to derive space_id. This also gives us the
# source message for the output line so the user sees what they forked.
commit = client.get_commit(args.checkpoint_id)
space_id = commit.get("repo_id", "")
fork = client.fork_session(
space_id=str(space_id),
checkpoint_id=args.checkpoint_id,
branch_name=args.branch or "",
)
if args.json:
_print_json(fork)
else:
print(format_fork_result(fork, commit), end="")
def cmd_compare(client: SmritiClient, args: argparse.Namespace) -> None:
result = client.compare_checkpoints(args.checkpoint_a, args.checkpoint_b)
if args.json:
_print_json(result)
else:
print(format_compare_result(result, full_artifacts=args.full_artifacts), end="")
def cmd_restore(client: SmritiClient, args: argparse.Namespace) -> None:
commit = client.get_commit(args.checkpoint_id)
space = client.get_space(str(commit.get("repo_id", "")))
if args.json:
_print_json({"space": space, "commit": commit})
else:
print(
format_restore_brief(space, commit, full_artifacts=args.full_artifacts),
end="",
)
# ── argparse wiring ────────────────────────────────────────────────────── # ── argparse wiring ──────────────────────────────────────────────────────
@ -337,6 +392,11 @@ def _build_parser() -> argparse.ArgumentParser:
"--from-json", "--from-json",
help="Path to a JSON file with the checkpoint payload (use '-' for stdin)", help="Path to a JSON file with the checkpoint payload (use '-' for stdin)",
) )
cp_create.add_argument(
"--session",
help="Attach the checkpoint to an existing session UUID instead of "
"creating a new one (used for fork workflows: pair with `smriti fork`)",
)
cp_create.add_argument("--json", action="store_true", help="Output structured JSON") cp_create.add_argument("--json", action="store_true", help="Output structured JSON")
cp_create.set_defaults(func=cmd_checkpoint_create) cp_create.set_defaults(func=cmd_checkpoint_create)
@ -377,6 +437,48 @@ def _build_parser() -> argparse.ArgumentParser:
cp_delete.add_argument("--json", action="store_true", help="Output structured JSON") cp_delete.add_argument("--json", action="store_true", help="Output structured JSON")
cp_delete.set_defaults(func=cmd_checkpoint_delete) cp_delete.set_defaults(func=cmd_checkpoint_delete)
# fork (top-level: crosses checkpoint → session)
fork_parser = subparsers.add_parser(
"fork",
help="Fork a new session from an existing checkpoint",
)
fork_parser.add_argument("checkpoint_id", help="Checkpoint UUID to fork from")
fork_parser.add_argument(
"--branch",
help="Branch name for the new session (default: branch-YYYY-MM-DD)",
)
fork_parser.add_argument("--json", action="store_true", help="Output structured JSON")
fork_parser.set_defaults(func=cmd_fork)
# restore (top-level: reads a specific checkpoint as a continuation brief)
restore_parser = subparsers.add_parser(
"restore",
help="Print a continuation-oriented brief of a specific checkpoint",
)
restore_parser.add_argument("checkpoint_id", help="Checkpoint UUID")
restore_parser.add_argument(
"--full-artifacts",
action="store_true",
help="Include full artifact content",
)
restore_parser.add_argument("--json", action="store_true", help="Output structured JSON")
restore_parser.set_defaults(func=cmd_restore)
# compare (top-level: operates on two checkpoints)
compare_parser = subparsers.add_parser(
"compare",
help="Compare two checkpoints and show the structured diff",
)
compare_parser.add_argument("checkpoint_a", help="First checkpoint UUID (side A)")
compare_parser.add_argument("checkpoint_b", help="Second checkpoint UUID (side B)")
compare_parser.add_argument(
"--full-artifacts",
action="store_true",
help="Include full artifact content in the diff view",
)
compare_parser.add_argument("--json", action="store_true", help="Output structured JSON")
compare_parser.set_defaults(func=cmd_compare)
return parser return parser