diff --git a/backend/app/api/routes/lineage.py b/backend/app/api/routes/lineage.py index 301e5c9..71b613a 100644 --- a/backend/app/api/routes/lineage.py +++ b/backend/app/api/routes/lineage.py @@ -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, ), ) diff --git a/backend/tests/integration/test_api_v5_lineage.py b/backend/tests/integration/test_api_v5_lineage.py index 3d36868..fd49e1e 100644 --- a/backend/tests/integration/test_api_v5_lineage.py +++ b/backend/tests/integration/test_api_v5_lineage.py @@ -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): diff --git a/cli/README.md b/cli/README.md index 2ed9228..42fb30a 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,16 +28,23 @@ Or pass `--api-url` on any command. ``` smriti space list smriti space create [--description "..."] +smriti space delete [-y] smriti state # continuation brief smriti state --full-artifacts # include full artifacts smriti state --json # structured output +smriti fork [--branch ] # new session from checkpoint +smriti restore # brief of a specific checkpoint +smriti compare # structured diff + smriti checkpoint create # reads JSON from stdin smriti checkpoint create --from-json # from file +smriti checkpoint create --session # attach to existing session smriti checkpoint show smriti checkpoint list smriti checkpoint review +smriti checkpoint delete [--cascade] [-y] ``` Every command supports `--json` for structured output. @@ -76,6 +83,32 @@ Review a specific checkpoint for consistency issues: smriti checkpoint review ``` +## 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 --branch experiment + +# The output gives you the new session ID. Write a checkpoint to that session: +cat <<'JSON' | smriti checkpoint create my-project --session +{ + "message": "Alternative design direction", + "summary": "...", + "decisions": ["Try stdlib only instead of click"] +} +JSON + +# Compare the two branches +smriti compare + +# Read any checkpoint as a continuation brief (what you'd need to continue from it) +smriti restore +``` + +`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. diff --git a/cli/smriti_cli/client.py b/cli/smriti_cli/client.py index 1c1ca4b..8162efc 100644 --- a/cli/smriti_cli/client.py +++ b/cli/smriti_cli/client.py @@ -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) diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index cc51705..0f87f8d 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -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 [] diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 905c037..fecda7d 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -6,12 +6,21 @@ Commands for agent and programmatic use: smriti space create [--description] smriti space delete [-y] smriti state - smriti checkpoint create # reads JSON from stdin + smriti fork [--branch ] + smriti restore + smriti compare + smriti checkpoint create [--session ] # reads JSON from stdin smriti checkpoint show smriti checkpoint list smriti checkpoint review smriti checkpoint delete [--cascade] [-y] +Multi-branch workflow: use `smriti fork ` to start a new +session on a new branch, then `smriti checkpoint create --session +` to write a checkpoint on that branch, then `smriti +compare ` to see how the branches diverged, and `smriti restore +` 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 , 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