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
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
|
@ -128,6 +129,7 @@ class CheckpointDiff(BaseModel):
|
|||
tasks_only_a: list[str]
|
||||
tasks_only_b: list[str]
|
||||
tasks_shared: list[str]
|
||||
common_ancestor_commit_id: Optional[uuid.UUID] = None
|
||||
|
||||
|
||||
class CompareResponse(BaseModel):
|
||||
|
|
@ -174,15 +176,108 @@ def _extract_text(item) -> str:
|
|||
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]]:
|
||||
"""Return (only_in_a, only_in_b, in_both) comparing by normalised text."""
|
||||
set_a = {_extract_text(x) for x in a}
|
||||
set_b = {_extract_text(x) for x in b}
|
||||
return (
|
||||
sorted(set_a - set_b),
|
||||
sorted(set_b - set_a),
|
||||
sorted(set_a & set_b),
|
||||
)
|
||||
"""Return (only_in_a, only_in_b, in_both) using normalized keys for
|
||||
matching but returning the original strings.
|
||||
|
||||
Matching uses _normalize_text as the equivalence key. When two items
|
||||
normalize to the same key, the A-side original wins (deterministic).
|
||||
Items whose normalized form is empty (e.g. "---", "..." or whitespace
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -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_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(
|
||||
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_b=task_only_b,
|
||||
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
|
||||
|
||||
|
||||
# ── 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 ──────────────────────
|
||||
|
||||
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 create <name> [--description "..."]
|
||||
smriti space delete <space> [-y]
|
||||
|
||||
smriti state <space> # continuation brief
|
||||
smriti state <space> --full-artifacts # include full artifacts
|
||||
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> --from-json <path> # from file
|
||||
smriti checkpoint create <space> --session <session-id> # attach to existing session
|
||||
smriti checkpoint show <checkpoint-id>
|
||||
smriti checkpoint list <space>
|
||||
smriti checkpoint review <checkpoint-id>
|
||||
smriti checkpoint delete <checkpoint-id> [--cascade] [-y]
|
||||
```
|
||||
|
||||
Every command supports `--json` for structured output.
|
||||
|
|
@ -76,6 +83,32 @@ Review a specific checkpoint for consistency issues:
|
|||
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
|
||||
|
||||
Only `message` is required. Every other field defaults to empty.
|
||||
|
|
|
|||
|
|
@ -155,6 +155,37 @@ class SmritiClient:
|
|||
def review_checkpoint(self, commit_id: str) -> dict:
|
||||
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:
|
||||
params = {"cascade": "true"} if cascade else None
|
||||
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:
|
||||
issues = result.get("issues") or []
|
||||
suggestions = result.get("suggestions") or []
|
||||
|
|
|
|||
|
|
@ -6,12 +6,21 @@ Commands for agent and programmatic use:
|
|||
smriti space create <name> [--description]
|
||||
smriti space delete <space> [-y]
|
||||
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 list <space>
|
||||
smriti checkpoint review <checkpoint-id>
|
||||
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.
|
||||
Default output is a readable markdown brief.
|
||||
|
||||
|
|
@ -29,6 +38,9 @@ from .client import SmritiClient, SmritiError
|
|||
from .formatters import (
|
||||
format_checkpoint,
|
||||
format_commit_list,
|
||||
format_compare_result,
|
||||
format_fork_result,
|
||||
format_restore_brief,
|
||||
format_review,
|
||||
format_space_list,
|
||||
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
|
||||
# have one — the CLI creates a lightweight session on demand and attaches
|
||||
# the checkpoint to it. Agents should not care about the session.
|
||||
session = client.create_session(
|
||||
repo_id=space["id"],
|
||||
title=f"cli: {payload['message'][:80]}",
|
||||
)
|
||||
# the checkpoint to it. With --session <id>, attach the checkpoint to an
|
||||
# existing session instead (used for fork workflows where the caller
|
||||
# already ran `smriti fork` and wants to write a checkpoint on the new
|
||||
# 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 = {
|
||||
"repo_id": space["id"],
|
||||
"session_id": session["id"],
|
||||
"session_id": session_id,
|
||||
"message": payload["message"],
|
||||
"summary": payload.get("summary", ""),
|
||||
"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}.")
|
||||
|
||||
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -337,6 +392,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
"--from-json",
|
||||
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.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.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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue