mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
73c71b4c9d
commit
2a6614bd80
6 changed files with 507 additions and 15 deletions
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue