diff --git a/backend/app/api/routes/checkpoint.py b/backend/app/api/routes/checkpoint.py index 826593e..d999f65 100644 --- a/backend/app/api/routes/checkpoint.py +++ b/backend/app/api/routes/checkpoint.py @@ -388,3 +388,82 @@ Rules: except Exception as e: logger.error(f"Extract LLM call failed: {e}") raise HTTPException(status_code=502, detail=f"Extract failed: {e}") + + +# ── Checkpoint notes ───────────────────────────────────────────────────────── + +from pydantic import BaseModel, Field +from datetime import datetime, timezone + +VALID_NOTE_KINDS = {"note", "milestone", "noise"} + + +class AddNoteRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=2000) + author: str = Field(default="founder", max_length=100) + kind: str = Field(default="note") + + +class NoteResponse(BaseModel): + id: str + author: str + text: str + kind: str + created_at: str + checkpoint_id: str + + +@router.post("/{checkpoint_id}/notes", response_model=NoteResponse, status_code=201) +def add_checkpoint_note( + checkpoint_id: uuid.UUID, + payload: AddNoteRequest, + db: Session = Depends(get_db), +): + """Add an additive note to a checkpoint without modifying its immutable fields. + + Notes are stored in the checkpoint's metadata_ JSONB field under the + 'notes' key. The checkpoint's decisions, summary, artifacts, and all + other fields remain untouched. Notes are append-only in v1. + """ + commit = db.get(CommitModel, checkpoint_id) + if not commit: + raise HTTPException(status_code=404, detail="Checkpoint not found") + + if payload.kind not in VALID_NOTE_KINDS: + raise HTTPException( + status_code=400, + detail=f"Invalid kind '{payload.kind}'. Must be one of: {', '.join(sorted(VALID_NOTE_KINDS))}", + ) + + note_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc).isoformat() + + note = { + "id": note_id, + "author": payload.author, + "text": payload.text, + "kind": payload.kind, + "created_at": now, + } + + # Append to existing notes array in metadata_, creating it if absent. + meta = dict(commit.metadata_ or {}) + notes = list(meta.get("notes", [])) + notes.append(note) + meta["notes"] = notes + commit.metadata_ = meta + + # Force SQLAlchemy to detect the JSONB mutation. + from sqlalchemy.orm.attributes import flag_modified + flag_modified(commit, "metadata_") + + db.commit() + + return NoteResponse( + id=note_id, + author=payload.author, + text=payload.text, + kind=payload.kind, + created_at=now, + checkpoint_id=str(checkpoint_id), + ) diff --git a/cli/smriti_cli/client.py b/cli/smriti_cli/client.py index 2518829..0fadce2 100644 --- a/cli/smriti_cli/client.py +++ b/cli/smriti_cli/client.py @@ -209,6 +209,22 @@ class SmritiClient: params["include_expired"] = "true" return self._request("GET", "/api/v5/claims", params=params) + # ── Checkpoint notes ────────────────────────────────────────────── + + def add_checkpoint_note( + self, + checkpoint_id: str, + text: str, + author: str = "founder", + kind: str = "note", + ) -> dict: + """POST /api/v5/checkpoint/{id}/notes — append a note.""" + return self._request( + "POST", + f"/api/v5/checkpoint/{checkpoint_id}/notes", + json={"text": text, "author": author, "kind": kind}, + ) + # ── Branch disposition ──────────────────────────────────────────── def close_branch(self, space_id: str, branch_name: str, disposition: str) -> dict: diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index eddf2dc..7604b5c 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -392,6 +392,20 @@ def format_checkpoint(commit: dict, *, full_artifacts: bool = False) -> str: if entities: parts.append(f"## Entities\n{', '.join(entities)}\n") + # Notes from metadata_ — additive founder/human annotations. + metadata = commit.get("metadata") or commit.get("metadata_") or {} + notes = metadata.get("notes") or [] + if notes: + lines = ["## Notes"] + for n in notes: + kind = n.get("kind", "note") + kind_prefix = f"[{kind}] " if kind != "note" else "" + author = n.get("author", "?") + created = _relative_time(n.get("created_at") or "") + text = n.get("text", "") + lines.append(f"- {kind_prefix}{author} · {created} — {text}") + parts.append("\n".join(lines) + "\n") + return "\n".join(p for p in parts if p).rstrip() + "\n" diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index 2b6a2af..a19e120 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -417,6 +417,21 @@ def cmd_checkpoint_delete(client: SmritiClient, args: argparse.Namespace) -> Non print(f"Deleted checkpoint `{commit['commit_hash'][:7]}`{note}.") +def cmd_checkpoint_note(client: SmritiClient, args: argparse.Namespace) -> None: + """Add a note to a checkpoint.""" + result = client.add_checkpoint_note( + checkpoint_id=args.checkpoint_id, + text=args.text, + author=args.author, + kind=args.kind, + ) + if args.json: + _print_json(result) + else: + kind_label = f" [{result['kind']}]" if result['kind'] != 'note' else "" + print(f"Note added to checkpoint `{result['checkpoint_id'][:8]}…`{kind_label}") + + 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. @@ -995,6 +1010,21 @@ 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) + cp_note = cp_sub.add_parser( + "note", + help="Add a note to a checkpoint (additive, does not modify checkpoint fields)", + ) + cp_note.add_argument("checkpoint_id", help="Checkpoint UUID to annotate") + cp_note.add_argument("--text", required=True, help="Note text (max 2000 chars)") + cp_note.add_argument("--author", default="founder", help="Author name (default: founder)") + cp_note.add_argument( + "--kind", default="note", + choices=["note", "milestone", "noise"], + help="Note kind (default: note)", + ) + cp_note.add_argument("--json", action="store_true") + cp_note.set_defaults(func=cmd_checkpoint_note) + # fork (top-level: crosses checkpoint → session) fork_parser = subparsers.add_parser( "fork", diff --git a/cli/smriti_cli/mcp_server.py b/cli/smriti_cli/mcp_server.py index baf38a8..960f4b7 100644 --- a/cli/smriti_cli/mcp_server.py +++ b/cli/smriti_cli/mcp_server.py @@ -470,6 +470,39 @@ def smriti_delete_space(space: str) -> str: return f"Deleted space '{s['name']}' and its {len(commits)} checkpoint(s).\n" +@mcp.tool() +def smriti_checkpoint_note( + checkpoint_id: str, + text: str, + author: str = "founder", + kind: str = "note", +) -> str: + """Add a note to a checkpoint without modifying its immutable fields. + + Notes are additive context — founder commentary, milestone markers, + or noise labels — stored alongside the checkpoint. The checkpoint's + decisions, summary, artifacts, and all other fields remain untouched. + + Args: + checkpoint_id: Checkpoint UUID to annotate. + text: Note text (max 2000 chars). + author: Author name (default: founder). + kind: One of: note (default), milestone, noise. + """ + client = _client() + try: + result = client.add_checkpoint_note( + checkpoint_id=checkpoint_id, + text=text, + author=author, + kind=kind, + ) + except SmritiError as e: + _raise_from(e) + kind_label = f" [{result['kind']}]" if result['kind'] != 'note' else "" + return f"Note added to checkpoint `{result['checkpoint_id'][:8]}…`{kind_label}" + + @mcp.tool() def smriti_close_branch( space: str, diff --git a/frontend/src/pages/CommitDetailPage.tsx b/frontend/src/pages/CommitDetailPage.tsx index b1936ec..eda6eb4 100644 --- a/frontend/src/pages/CommitDetailPage.tsx +++ b/frontend/src/pages/CommitDetailPage.tsx @@ -235,6 +235,35 @@ export function CommitDetailPage() { {!commit.objective && !commit.summary && commit.tasks.length === 0 && (
No structured state recorded in this commit.
)} + + {/* Notes — additive founder annotations */} + {commit.metadata?.notes && commit.metadata.notes.length > 0 && ( +{n.text}
+