Add Project Current State endpoint and UI panel

Add GET /api/v5/current/spaces/{id} — a packaged, computed-on-demand
snapshot of where a space is right now: current direction, counts,
attention signals (open questions, divergence, active work), active
work claims, open tasks grouped by intent, recent milestones, and
recent activity. Shared payload contract with the smriti current CLI
surface built in parallel.

Render it as the ProjectCurrentState panel at the top of LineagePage,
replacing the hand-rolled current-state summary. Extract a shared
_get_active_claims helper so the state and current endpoints report
active work identically. No schema changes.
This commit is contained in:
Himanshu Dongre 2026-05-16 20:01:16 +05:30
parent c470947477
commit ef8faf6e4d
8 changed files with 1078 additions and 143 deletions

View file

@ -936,6 +936,63 @@ def _compute_space_divergence(
return DivergenceSummary(pairs=pairs) if pairs else None
def _get_active_claims(
repo_id: uuid.UUID, db: Session, limit: int = 10
) -> list[ActiveClaimSummary]:
"""Active, non-expired work claims for a space, most-recently-claimed first.
Claims bound to an active worktree are enriched with cached git drift
(path, branch, dirty file count, ahead/behind vs origin/main, last
commit). Shared by `GET /spaces/{id}/state` and
`GET /api/v5/current/spaces/{id}` so both surfaces report active work
identically.
"""
now = _utcnow()
claims_stmt = (
select(WorkClaim)
.where(
WorkClaim.repo_id == repo_id,
WorkClaim.status == "active",
WorkClaim.expires_at > now,
)
.order_by(WorkClaim.claimed_at.desc())
.limit(limit)
)
active_claims: list[ActiveClaimSummary] = []
for wc in db.scalars(claims_stmt):
base_hash = None
if wc.base_commit_id:
base_commit = db.get(CommitModel, wc.base_commit_id)
base_hash = base_commit.commit_hash[:7] if base_commit else None
worktree_summary = None
if wc.worktree_id:
worktree = db.get(WorkTree, wc.worktree_id)
if worktree and worktree.status == "active":
probed = _probe_worktree(
str(worktree.id),
worktree.path,
worktree.branch_name,
)
if probed:
worktree_summary = ActiveWorktreeSummary(**probed)
active_claims.append(
ActiveClaimSummary(
id=wc.id,
agent=wc.agent,
branch_name=wc.branch_name,
scope=wc.scope,
task_id=wc.task_id,
worktree_id=wc.worktree_id,
worktree=worktree_summary,
intent_type=wc.intent_type,
claimed_at=wc.claimed_at,
expires_at=wc.expires_at,
base_commit_hash=base_hash,
)
)
return active_claims
FRESHNESS_NEW_CHECKPOINTS_CAP = 5
@ -1016,50 +1073,8 @@ def get_space_state(
if main_head_commit and active_branch_commits:
divergence = _compute_space_divergence(main_head_commit, active_branch_commits)
# Active work claims — query-time expiration filter.
now = _utcnow()
claims_stmt = (
select(WorkClaim)
.where(
WorkClaim.repo_id == repo_id,
WorkClaim.status == "active",
WorkClaim.expires_at > now,
)
.order_by(WorkClaim.claimed_at.desc())
.limit(10)
)
active_claims = []
for wc in db.scalars(claims_stmt):
base_hash = None
if wc.base_commit_id:
base_commit = db.get(CommitModel, wc.base_commit_id)
base_hash = base_commit.commit_hash[:7] if base_commit else None
worktree_summary = None
if wc.worktree_id:
worktree = db.get(WorkTree, wc.worktree_id)
if worktree and worktree.status == "active":
probed = _probe_worktree(
str(worktree.id),
worktree.path,
worktree.branch_name,
)
if probed:
worktree_summary = ActiveWorktreeSummary(**probed)
active_claims.append(
ActiveClaimSummary(
id=wc.id,
agent=wc.agent,
branch_name=wc.branch_name,
scope=wc.scope,
task_id=wc.task_id,
worktree_id=wc.worktree_id,
worktree=worktree_summary,
intent_type=wc.intent_type,
claimed_at=wc.claimed_at,
expires_at=wc.expires_at,
base_commit_hash=base_hash,
)
)
# Active work claims — extracted helper, shared with GET /api/v5/current.
active_claims = _get_active_claims(repo_id, db)
# Freshness check: if since_commit_id is provided, determine whether
# HEAD has moved and list new checkpoints since the caller's base.

View file

@ -0,0 +1,350 @@
"""
V5 Project Current State API a compact, legible snapshot of where a
space is *right now*.
This is the founder-facing / agent-facing "what is happening, what changed,
what needs my attention" surface. It is computed on demand from existing
tables (commits, work_claims, chat_sessions) no new schema, no events,
no background jobs. The CLI renders the same payload via `smriti current`.
Endpoint:
GET /api/v5/current/spaces/{space_id} packaged current-state snapshot
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.routes.chat import (
ActiveClaimSummary,
_compute_space_divergence,
_get_active_branch_heads,
_get_active_claims,
_get_latest_commit,
_get_repo,
)
from app.db.database import get_db
from app.db.models import CommitModel
router = APIRouter(prefix="/current", tags=["current-v5"])
# ── Caps & vocabulary ────────────────────────────────────────────────────────
#
# Constants on purpose — this surface is "digestible by default." A caller
# who needs unbounded history hits the lineage / metrics endpoints instead.
INTENT_ORDER = ("implement", "review", "test", "docs", "investigate", "other")
VALID_INTENTS = {"implement", "review", "test", "docs", "investigate"}
RECENT_MILESTONES_CAP = 5
RECENT_ACTIVITY_CAP = 8
OPEN_QUESTIONS_CAP = 6
TASKS_PER_INTENT_CAP = 8
SUMMARY_PREVIEW_CHARS = 280
# ── Response schemas ─────────────────────────────────────────────────────────
class CurrentDirection(BaseModel):
"""Where the project is headed right now — drawn from the latest
main-branch checkpoint. All fields are None for a space with no
checkpoints yet."""
objective: Optional[str] = None
headline: Optional[str] = None # latest main checkpoint message/title
summary: Optional[str] = None # truncated to SUMMARY_PREVIEW_CHARS
checkpoint_id: Optional[uuid.UUID] = None
checkpoint_hash: Optional[str] = None
author_agent: Optional[str] = None
updated_at: Optional[datetime] = None
class CurrentCounts(BaseModel):
"""At-a-glance project size signals."""
checkpoints: int = 0
agents: int = 0
active_claims: int = 0
active_branches: int = 0
open_tasks: int = 0 # open structured tasks on the latest main checkpoint
milestones: int = 0 # total milestone notes across all checkpoints
class AttentionSignal(BaseModel):
"""One "needs my attention right now" item.
kind is one of:
- "open_question" an unresolved question on the latest checkpoint
- "divergence" an active branch disagrees with main on decisions
- "active_work" an agent currently holds a claim
severity is "info" or "warn".
"""
kind: str
severity: str
message: str
class MilestoneEntry(BaseModel):
"""A milestone note and the checkpoint it annotates."""
checkpoint_id: uuid.UUID
checkpoint_hash: str
checkpoint_message: str
note: str
author_agent: Optional[str] = None
created_at: datetime
class CurrentTask(BaseModel):
"""A structured task, normalized from the latest checkpoint's task list."""
text: str
id: Optional[str] = None
intent_hint: Optional[str] = None
blocked_by: Optional[str] = None
status: str = "open"
class RecentActivityEntry(BaseModel):
"""One recent checkpoint, newest-first. Spans all branches so the
surface honestly reflects 'what has been happening'."""
checkpoint_id: uuid.UUID
checkpoint_hash: str
message: str
author_agent: Optional[str] = None
branch_name: str
created_at: datetime
has_milestone: bool = False
class CurrentStateResponse(BaseModel):
"""Composite, packaged snapshot for `GET /current/spaces/{id}`.
One round trip. Shared payload contract between this endpoint, the
`smriti current` CLI command, and the Project Current State UI panel.
"""
space_id: uuid.UUID
name: str
description: Optional[str] = None
current_direction: CurrentDirection
counts: CurrentCounts
attention: list[AttentionSignal] = Field(default_factory=list)
active_work: list[ActiveClaimSummary] = Field(default_factory=list)
recent_milestones: list[MilestoneEntry] = Field(default_factory=list)
open_tasks_by_intent: dict[str, list[CurrentTask]] = Field(default_factory=dict)
recent_activity: list[RecentActivityEntry] = Field(default_factory=list)
# ── Helpers ──────────────────────────────────────────────────────────────────
def _normalize_task(raw: object) -> Optional[CurrentTask]:
"""Coerce a raw task entry into a CurrentTask.
Tasks in `CommitModel.tasks` (JSONB) are either legacy strings or
structured dicts. Returns None for empty / unusable entries.
"""
if isinstance(raw, str):
text = raw.strip()
return CurrentTask(text=text) if text else None
if isinstance(raw, dict):
text = str(raw.get("text") or "").strip()
if not text:
return None
status = raw.get("status") or "open"
intent = raw.get("intent_hint")
return CurrentTask(
text=text,
id=(raw.get("id") or None),
intent_hint=(intent or None),
blocked_by=(raw.get("blocked_by") or None),
status=status if status in ("open", "done") else "open",
)
return None
# ── Endpoint ─────────────────────────────────────────────────────────────────
@router.get("/spaces/{space_id}", response_model=CurrentStateResponse)
def get_current_state(space_id: uuid.UUID, db: Session = Depends(get_db)):
"""Return the packaged current-state snapshot for a space.
Contains:
- space header (id, name, description)
- current_direction the latest main-branch checkpoint
- counts checkpoints, agents, active claims, active branches,
open tasks, milestones
- attention open questions, branch divergence, and active claims,
as a flat list of typed signals
- active_work active work claims (with worktree drift when bound)
- recent_milestones milestone notes, newest-first, capped
- open_tasks_by_intent open structured tasks from the latest main
checkpoint, grouped by intent
- recent_activity recent checkpoints across all branches, capped
All data is derived from existing tables. No schema, no events.
"""
repo = _get_repo(space_id, db)
# One ordered fetch of every checkpoint, newest-first. The metrics
# endpoint uses the same all-in-Python pattern — fast at smriti scale.
all_commits = list(
db.scalars(
select(CommitModel)
.where(CommitModel.repo_id == space_id)
.order_by(CommitModel.created_at.desc())
)
)
main_head = _get_latest_commit(space_id, db)
# ── current_direction ────────────────────────────────────────────
if main_head:
direction = CurrentDirection(
objective=(main_head.objective or None),
headline=(main_head.message or None),
summary=((main_head.summary or "")[:SUMMARY_PREVIEW_CHARS] or None),
checkpoint_id=main_head.id,
checkpoint_hash=main_head.commit_hash,
author_agent=main_head.author_agent,
updated_at=main_head.created_at,
)
else:
direction = CurrentDirection()
# ── single pass: recent activity + milestone scan ────────────────
recent_activity: list[RecentActivityEntry] = []
recent_milestones: list[MilestoneEntry] = []
milestone_total = 0
for c in all_commits:
notes = (c.metadata_ or {}).get("notes") or []
milestone_notes = [
n for n in notes
if isinstance(n, dict) and n.get("kind") == "milestone"
]
milestone_total += len(milestone_notes)
if len(recent_activity) < RECENT_ACTIVITY_CAP:
recent_activity.append(
RecentActivityEntry(
checkpoint_id=c.id,
checkpoint_hash=c.commit_hash,
message=c.message or "",
author_agent=c.author_agent,
branch_name=c.branch_name,
created_at=c.created_at,
has_milestone=bool(milestone_notes),
)
)
for n in milestone_notes:
if len(recent_milestones) >= RECENT_MILESTONES_CAP:
break
note_text = str(n.get("text") or "").strip()
if not note_text:
continue
recent_milestones.append(
MilestoneEntry(
checkpoint_id=c.id,
checkpoint_hash=c.commit_hash,
checkpoint_message=c.message or "",
note=note_text,
author_agent=(n.get("author") or c.author_agent),
created_at=(n.get("created_at") or c.created_at),
)
)
# ── counts inputs ────────────────────────────────────────────────
agents = {c.author_agent for c in all_commits if c.author_agent}
active_branch_commits = _get_active_branch_heads(space_id, db)
active_claims = _get_active_claims(space_id, db)
# ── open tasks grouped by intent (latest main checkpoint) ────────
open_tasks_by_intent: dict[str, list[CurrentTask]] = {}
open_task_total = 0
if main_head:
grouped: dict[str, list[CurrentTask]] = {k: [] for k in INTENT_ORDER}
for raw in (main_head.tasks or []):
task = _normalize_task(raw)
if task is None or task.status == "done":
continue
intent = task.intent_hint if task.intent_hint in VALID_INTENTS else "other"
grouped[intent].append(task)
open_task_total += 1
# Fixed intent order; only non-empty groups appear in the dict.
for intent in INTENT_ORDER:
capped = grouped[intent][:TASKS_PER_INTENT_CAP]
if capped:
open_tasks_by_intent[intent] = capped
counts = CurrentCounts(
checkpoints=len(all_commits),
agents=len(agents),
active_claims=len(active_claims),
active_branches=len(active_branch_commits),
open_tasks=open_task_total,
milestones=milestone_total,
)
# ── attention signals ────────────────────────────────────────────
attention: list[AttentionSignal] = []
# Open questions on the latest main checkpoint.
if main_head:
open_qs = [
str(q).strip()
for q in (main_head.open_questions or [])
if str(q).strip()
]
for text in open_qs[:OPEN_QUESTIONS_CAP]:
attention.append(
AttentionSignal(kind="open_question", severity="info", message=text)
)
# Branch divergence — an active branch disagrees with main on decisions.
if main_head and active_branch_commits:
divergence = _compute_space_divergence(main_head, active_branch_commits)
if divergence and divergence.pairs:
for pair in divergence.pairs:
attention.append(
AttentionSignal(
kind="divergence",
severity="warn",
message=(
f"Branch '{pair.branch_name}' "
f"({pair.branch_commit_hash[:7]}) diverges from main "
f"on decisions — run compare to reconcile."
),
)
)
# Active work — an agent currently holds a claim. Part of the
# founder-facing "check before starting overlapping work" story.
for claim in active_claims:
attention.append(
AttentionSignal(
kind="active_work",
severity="info",
message=(
f"{claim.agent} is working on \"{claim.scope}\" "
f"[{claim.intent_type}] — check before starting "
f"overlapping work."
),
)
)
return CurrentStateResponse(
space_id=repo.id,
name=repo.name,
description=repo.description,
current_direction=direction,
counts=counts,
attention=attention,
active_work=active_claims,
recent_milestones=recent_milestones,
open_tasks_by_intent=open_tasks_by_intent,
recent_activity=recent_activity,
)

View file

@ -59,6 +59,7 @@ def create_app() -> FastAPI:
claims,
commits,
context_git,
current,
lineage,
metrics,
repos,
@ -78,6 +79,7 @@ def create_app() -> FastAPI:
app.include_router(lineage.router, prefix="/api/v5", tags=["lineage-v5"])
app.include_router(claims.router, prefix="/api/v5", tags=["claims-v5"])
app.include_router(metrics.router, prefix="/api/v5", tags=["metrics-v5"])
app.include_router(current.router, prefix="/api/v5", tags=["current-v5"])
app.include_router(worktrees.router, prefix="/api/v5", tags=["worktrees-v5"])
# ── Capabilities manifest ────────────────────────────────────────

View file

@ -0,0 +1,302 @@
"""Integration tests for the Project Current State endpoint.
Covers `GET /api/v5/current/spaces/{space_id}` end-to-end through the
FastAPI app with an in-memory SQLite session. No mocking.
- Empty space returns a usable, fully-empty shape
- current_direction is drawn from the latest main checkpoint
- counts aggregate checkpoints / agents / claims / branches / tasks / milestones
- open_tasks_by_intent groups open structured tasks, excludes done tasks
- recent_milestones surfaces milestone notes, newest-first, capped at 5
- recent_activity surfaces recent checkpoints, capped at 8
- attention carries open_question, divergence, and active_work signals
- unknown space returns 404
"""
from __future__ import annotations
import uuid
# ── Helpers (match the shape used in test_multi_branch_state.py) ─────────────
def _create_repo(client, name: str = "Current State Test") -> str:
r = client.post("/api/v2/repos", json={"name": name})
assert r.status_code == 201, r.text
return r.json()["id"]
def _create_session(client, repo_id: str, title: str = "test") -> str:
r = client.post(
f"/api/v4/chat/spaces/{repo_id}/sessions",
json={"title": title, "provider": "openrouter", "model": "mock"},
)
assert r.status_code == 201, r.text
return r.json()["id"]
def _commit(client, repo_id: str, session_id: str, message: str = "checkpoint", **kwargs) -> dict:
payload = {"repo_id": repo_id, "session_id": session_id, "message": message, **kwargs}
r = client.post("/api/v4/chat/commit", json=payload)
assert r.status_code == 201, r.text
return r.json()
def _fork(client, space_id: str, checkpoint_id: str, branch_name: str = "") -> dict:
r = client.post(
"/api/v5/lineage/sessions/fork",
json={
"space_id": space_id,
"checkpoint_id": checkpoint_id,
"branch_name": branch_name,
"provider": "openrouter",
"model": "mock",
},
)
assert r.status_code == 201, r.text
return r.json()
def _add_note(
client, checkpoint_id: str, text: str, kind: str = "note", author: str = "founder"
) -> dict:
r = client.post(
f"/api/v5/checkpoint/{checkpoint_id}/notes",
json={"text": text, "kind": kind, "author": author},
)
assert r.status_code == 201, r.text
return r.json()
def _create_claim(
client, space_id: str, agent: str, scope: str, intent_type: str = "implement"
) -> dict:
r = client.post(
"/api/v5/claims",
json={"space_id": space_id, "agent": agent, "scope": scope, "intent_type": intent_type},
)
assert r.status_code == 201, r.text
return r.json()
def _get_current(client, space_id: str) -> dict:
r = client.get(f"/api/v5/current/spaces/{space_id}")
assert r.status_code == 200, r.text
return r.json()
# ── Tests ────────────────────────────────────────────────────────────────────
def test_current_empty_space(client):
"""A space with no checkpoints returns a usable, fully-empty payload."""
repo_id = _create_repo(client, "Empty Current")
cur = _get_current(client, repo_id)
assert cur["space_id"] == repo_id
assert cur["name"] == "Empty Current"
assert cur["current_direction"]["objective"] is None
assert cur["current_direction"]["checkpoint_id"] is None
assert cur["counts"] == {
"checkpoints": 0,
"agents": 0,
"active_claims": 0,
"active_branches": 0,
"open_tasks": 0,
"milestones": 0,
}
assert cur["attention"] == []
assert cur["active_work"] == []
assert cur["recent_milestones"] == []
assert cur["open_tasks_by_intent"] == {}
assert cur["recent_activity"] == []
def test_current_direction_from_latest_main_checkpoint(client):
"""current_direction mirrors the latest main checkpoint."""
repo_id = _create_repo(client, "Direction")
session_id = _create_session(client, repo_id)
commit = _commit(
client,
repo_id,
session_id,
message="Build the current-state layer",
objective="Ship Project Current State",
summary="Backend endpoint plus a UI panel.",
author_agent="claude-code",
)
cur = _get_current(client, repo_id)
direction = cur["current_direction"]
assert direction["headline"] == "Build the current-state layer"
assert direction["objective"] == "Ship Project Current State"
assert direction["summary"] == "Backend endpoint plus a UI panel."
assert direction["checkpoint_id"] == commit["id"]
assert direction["checkpoint_hash"] == commit["commit_hash"]
assert direction["author_agent"] == "claude-code"
assert cur["counts"]["checkpoints"] == 1
assert cur["counts"]["agents"] == 1
assert len(cur["recent_activity"]) == 1
assert cur["recent_activity"][0]["message"] == "Build the current-state layer"
assert cur["recent_activity"][0]["has_milestone"] is False
def test_current_open_tasks_grouped_by_intent(client):
"""open_tasks_by_intent groups open tasks; done tasks are excluded."""
repo_id = _create_repo(client, "Tasks")
session_id = _create_session(client, repo_id)
_commit(
client,
repo_id,
session_id,
message="Has tasks",
tasks=[
{
"text": "Implement endpoint",
"intent_hint": "implement",
"id": "impl-1",
"status": "open",
},
{"text": "Write tests", "intent_hint": "test", "status": "open"},
{"text": "Update docs", "intent_hint": "docs", "status": "done"},
{"text": "Unlabelled work", "status": "open"},
],
)
cur = _get_current(client, repo_id)
grouped = cur["open_tasks_by_intent"]
assert set(grouped.keys()) == {"implement", "test", "other"}
assert "docs" not in grouped # its only task is done
assert len(grouped["implement"]) == 1
assert grouped["implement"][0]["text"] == "Implement endpoint"
assert grouped["implement"][0]["id"] == "impl-1"
assert grouped["other"][0]["text"] == "Unlabelled work"
assert cur["counts"]["open_tasks"] == 3 # done task excluded
def test_current_recent_milestones(client):
"""Milestone notes surface in recent_milestones; plain notes do not."""
repo_id = _create_repo(client, "Milestones")
session_id = _create_session(client, repo_id)
commit = _commit(client, repo_id, session_id, message="Proof checkpoint")
_add_note(client, commit["id"], "First clean autonomous proof", kind="milestone")
_add_note(client, commit["id"], "just a plain note", kind="note")
cur = _get_current(client, repo_id)
assert len(cur["recent_milestones"]) == 1
ms = cur["recent_milestones"][0]
assert ms["note"] == "First clean autonomous proof"
assert ms["checkpoint_hash"] == commit["commit_hash"]
assert ms["checkpoint_message"] == "Proof checkpoint"
assert cur["counts"]["milestones"] == 1 # plain note not counted
assert cur["recent_activity"][0]["has_milestone"] is True
def test_current_attention_open_questions(client):
"""Open questions on the latest checkpoint become open_question signals."""
repo_id = _create_repo(client, "Questions")
session_id = _create_session(client, repo_id)
_commit(
client,
repo_id,
session_id,
message="Has questions",
open_questions=["Should state be cached?", "How do we page large spaces?"],
)
cur = _get_current(client, repo_id)
oq = [a for a in cur["attention"] if a["kind"] == "open_question"]
assert len(oq) == 2
assert {a["message"] for a in oq} == {
"Should state be cached?",
"How do we page large spaces?",
}
assert all(a["severity"] == "info" for a in oq)
def test_current_attention_active_work(client):
"""An active claim appears in active_work and as an active_work signal."""
repo_id = _create_repo(client, "Active Work")
session_id = _create_session(client, repo_id)
_commit(client, repo_id, session_id, message="Base")
_create_claim(client, repo_id, agent="codex-local", scope="Wire the CLI surface")
cur = _get_current(client, repo_id)
assert cur["counts"]["active_claims"] == 1
assert len(cur["active_work"]) == 1
assert cur["active_work"][0]["agent"] == "codex-local"
aw = [a for a in cur["attention"] if a["kind"] == "active_work"]
assert len(aw) == 1
assert "codex-local" in aw[0]["message"]
assert "Wire the CLI surface" in aw[0]["message"]
def test_current_attention_divergence(client):
"""A divergent active branch produces a divergence attention signal."""
repo_id = _create_repo(client, "Divergence")
session_id = _create_session(client, repo_id)
base = _commit(
client,
repo_id,
session_id,
message="Base",
decisions=["Use Postgres for storage"],
)
fork = _fork(client, repo_id, base["id"], branch_name="sqlite-route")
_commit(
client,
repo_id,
fork["session_id"],
message="Try SQLite",
decisions=["Use embedded SQLite only", "Drop the Postgres dependency"],
)
cur = _get_current(client, repo_id)
assert cur["counts"]["active_branches"] == 1
div = [a for a in cur["attention"] if a["kind"] == "divergence"]
assert len(div) == 1
assert div[0]["severity"] == "warn"
assert "sqlite-route" in div[0]["message"]
def test_current_recent_activity_capped_at_8(client):
"""recent_activity is capped at 8 and ordered newest-first."""
repo_id = _create_repo(client, "Many Checkpoints")
session_id = _create_session(client, repo_id)
for i in range(10):
_commit(client, repo_id, session_id, message=f"checkpoint-{i:02d}")
cur = _get_current(client, repo_id)
assert cur["counts"]["checkpoints"] == 10
assert len(cur["recent_activity"]) == 8
timestamps = [e["created_at"] for e in cur["recent_activity"]]
assert timestamps == sorted(timestamps, reverse=True) # newest-first
def test_current_recent_milestones_capped_at_5(client):
"""recent_milestones caps at 5 even though counts.milestones is the true total."""
repo_id = _create_repo(client, "Many Milestones")
session_id = _create_session(client, repo_id)
for i in range(6):
commit = _commit(client, repo_id, session_id, message=f"milestone-cp-{i}")
_add_note(client, commit["id"], f"Milestone {i}", kind="milestone")
cur = _get_current(client, repo_id)
assert len(cur["recent_milestones"]) == 5
assert cur["counts"]["milestones"] == 6
def test_current_unknown_space_returns_404(client):
"""An unknown space id returns 404."""
r = client.get(f"/api/v5/current/spaces/{uuid.uuid4()}")
assert r.status_code == 404

View file

@ -361,6 +361,15 @@ export async function getSpaceState(spaceId: string): Promise<import('../types')
return requestV4<import('../types').SpaceStateResponse>(`/chat/spaces/${spaceId}/state`);
}
/**
* Packaged "Project Current State" snapshot for a space current direction,
* counts, attention signals, active work, open tasks by intent, recent
* milestones, and recent activity. One round trip.
*/
export async function getCurrentState(spaceId: string): Promise<import('../types').CurrentState> {
return requestV5<import('../types').CurrentState>(`/current/spaces/${spaceId}`);
}
export async function compareCheckpoints(
aId: string,
bId: string,

View file

@ -0,0 +1,281 @@
/**
* ProjectCurrentState the packaged "where is this project right now" panel.
*
* Renders GET /api/v5/current/spaces/{id}: current direction, counts,
* attention signals, active work, open tasks grouped by intent, recent
* milestones, and recent activity. Compact and scannable the
* founder-facing current-state surface. Empty sub-sections are elided.
*/
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { getCurrentState } from '../api/client';
import type { CurrentState } from '../types';
import { Activity, AlertTriangle, Compass, ListChecks, Loader2, Star, Zap } from 'lucide-react';
const PANEL_STYLE = {
borderColor: 'var(--color-border, #27272a)',
background: 'var(--color-surface, #18181b)',
} as const;
const INTENT_LABELS: Record<string, string> = {
implement: 'Implement',
review: 'Review',
test: 'Test',
docs: 'Docs',
investigate: 'Investigate',
other: 'Other',
};
function fmt(d: string) {
return new Date(d).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ── Sub-components ────────────────────────────────────────────────────────────
function Section({
icon: Icon,
title,
iconClass,
children,
}: {
icon: React.ComponentType<{ className?: string }>;
title: string;
iconClass?: string;
children: ReactNode;
}) {
return (
<div>
<div className="flex items-center gap-1.5 mb-2">
<Icon className={`w-3.5 h-3.5 ${iconClass ?? 'text-gray-500'}`} />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">{title}</h3>
</div>
{children}
</div>
);
}
function Stat({ n, label, tone }: { n: number; label: string; tone?: string }) {
return (
<span className="text-gray-500">
<span className={`font-medium ${tone ?? 'text-white'}`}>{n}</span> {label}
{n === 1 ? '' : 's'}
</span>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function ProjectCurrentState({ spaceId }: { spaceId: string }) {
const [state, setState] = useState<CurrentState | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
useEffect(() => {
if (!spaceId) return;
setLoading(true);
setErr('');
getCurrentState(spaceId)
.then(setState)
.catch((e: unknown) => setErr(e instanceof Error ? e.message : 'Failed to load current state'))
.finally(() => setLoading(false));
}, [spaceId]);
if (loading) {
return (
<section className="rounded-xl border p-5 flex justify-center" style={PANEL_STYLE}>
<Loader2 className="w-5 h-5 animate-spin text-gray-500" />
</section>
);
}
if (err || !state) {
return (
<section className="rounded-xl border p-5" style={PANEL_STYLE}>
<p className="text-xs text-red-400">{err || 'No current state available.'}</p>
</section>
);
}
const dir = state.current_direction;
const hasDirection = Boolean(dir.checkpoint_id);
const counts = state.counts;
const hasWarn = state.attention.some(a => a.severity === 'warn');
const intentKeys = Object.keys(state.open_tasks_by_intent);
return (
<section className="rounded-xl border p-5 space-y-4" style={PANEL_STYLE}>
{/* Header */}
<div>
<h2 className="text-base font-semibold text-white">{state.name}</h2>
{state.description && <p className="text-xs text-gray-500 mt-0.5">{state.description}</p>}
</div>
{/* Current direction */}
{hasDirection ? (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-[10px] text-gray-600 uppercase tracking-wider">
<Compass className="w-3 h-3" />
Current direction
</div>
<p className="text-sm text-gray-200">{dir.headline}</p>
{dir.objective && <p className="text-xs text-gray-500">{dir.objective}</p>}
<div className="flex items-center gap-2 text-[10px] text-gray-600 flex-wrap">
{dir.author_agent && (
<span className="font-mono border border-gray-700 px-1.5 py-px rounded text-gray-500">
{dir.author_agent}
</span>
)}
{dir.checkpoint_hash && <code className="text-blue-400">{dir.checkpoint_hash.slice(0, 7)}</code>}
{dir.updated_at && <span>{fmt(dir.updated_at)}</span>}
</div>
</div>
) : (
<p className="text-xs text-gray-600">No checkpoints yet this space has no recorded direction.</p>
)}
{/* Counts strip */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] border-t border-b py-2"
style={{ borderColor: 'var(--color-border, #27272a)' }}>
<Stat n={counts.checkpoints} label="checkpoint" />
<Stat n={counts.agents} label="agent" />
<Stat n={counts.active_claims} label="active claim" tone="text-amber-400" />
<Stat n={counts.active_branches} label="active branch" tone="text-purple-400" />
<Stat n={counts.open_tasks} label="open task" tone="text-green-400" />
<Stat n={counts.milestones} label="milestone" tone="text-amber-400" />
</div>
{/* Attention */}
{state.attention.length > 0 && (
<div
className={`rounded-lg border px-3 py-2 ${
hasWarn ? 'border-red-500/30 bg-red-900/10' : 'border-amber-500/30 bg-amber-900/10'
}`}
>
<div
className={`flex items-center gap-1.5 text-[11px] font-medium mb-1 ${
hasWarn ? 'text-red-400' : 'text-amber-400'
}`}
>
<AlertTriangle className="w-3.5 h-3.5" />
Needs attention
</div>
<ul className="space-y-0.5 text-[11px]">
{state.attention.map((a, i) => (
<li
key={i}
className={a.severity === 'warn' ? 'text-red-300/90' : 'text-amber-300/80'}
>
{a.message}
</li>
))}
</ul>
</div>
)}
{/* Active work */}
{state.active_work.length > 0 && (
<Section icon={Zap} title="Active work" iconClass="text-amber-400">
<div className="space-y-1.5">
{state.active_work.map(claim => (
<div key={claim.id} className="rounded-lg border border-amber-500/20 bg-amber-900/5 px-3 py-2">
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
<span className="text-[10px] text-amber-400 font-mono border border-amber-500/30 px-1.5 py-px rounded">
{claim.agent}
</span>
<span className="text-[10px] text-gray-500 border border-gray-700 px-1.5 py-px rounded">
{claim.intent_type}
</span>
<span className="text-[10px] text-gray-600">
on <code className="text-gray-500">{claim.branch_name}</code>
</span>
{claim.task_id && (
<span className="text-[10px] text-gray-600">
task: <code className="text-gray-500">{claim.task_id}</code>
</span>
)}
</div>
<p className="text-xs text-gray-300">{claim.scope}</p>
</div>
))}
</div>
</Section>
)}
{/* Open tasks by intent */}
{intentKeys.length > 0 && (
<Section icon={ListChecks} title="Open tasks">
<div className="space-y-2">
{intentKeys.map(intent => (
<div key={intent}>
<p className="text-[9px] uppercase tracking-wider text-gray-600 mb-1">
{INTENT_LABELS[intent] ?? intent}
</p>
<ul className="space-y-0.5">
{state.open_tasks_by_intent[intent].map((t, i) => (
<li key={t.id ?? `${intent}-${i}`} className="text-[11px] text-gray-400 flex items-start gap-1.5">
<span className="text-gray-700 mt-px"></span>
<span>
{t.text}
{t.blocked_by && (
<span className="text-amber-500/70"> blocked by {t.blocked_by}</span>
)}
</span>
</li>
))}
</ul>
</div>
))}
</div>
</Section>
)}
{/* Recent milestones */}
{state.recent_milestones.length > 0 && (
<Section icon={Star} title="Recent milestones" iconClass="text-amber-400">
<ul className="space-y-1.5">
{state.recent_milestones.map((m, i) => (
<li key={`${m.checkpoint_id}-${i}`} className="text-[11px]">
<div className="flex items-center gap-2 text-[10px] text-gray-600">
<code className="text-blue-400">{m.checkpoint_hash.slice(0, 7)}</code>
{m.author_agent && <span className="font-mono">{m.author_agent}</span>}
<span>{fmt(m.created_at)}</span>
</div>
<p className="text-gray-400 mt-0.5">{m.note}</p>
</li>
))}
</ul>
</Section>
)}
{/* Recent activity */}
{state.recent_activity.length > 0 && (
<Section icon={Activity} title="Recent activity">
<ul className="space-y-1">
{state.recent_activity.map(a => (
<li key={a.checkpoint_id} className="flex items-center gap-2 text-[11px]">
<code className="text-blue-400 flex-shrink-0">{a.checkpoint_hash.slice(0, 7)}</code>
{a.has_milestone && <Star className="w-3 h-3 text-amber-400 flex-shrink-0" />}
<span className="text-gray-400 truncate flex-1">{a.message}</span>
{a.branch_name !== 'main' && (
<span className="text-[9px] text-purple-400 bg-purple-500/10 border border-purple-500/30 px-1.5 py-px rounded flex-shrink-0">
{a.branch_name}
</span>
)}
{a.author_agent && (
<span className="text-[9px] text-gray-600 font-mono flex-shrink-0">{a.author_agent}</span>
)}
<span className="text-[10px] text-gray-600 flex-shrink-0">{fmt(a.created_at)}</span>
</li>
))}
</ul>
</Section>
)}
</section>
);
}

View file

@ -18,15 +18,14 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { getLineage, getSpaceState, compareCheckpoints, forkSession } from '../api/client';
import { ProjectCurrentState } from '../components/ProjectCurrentState';
import type {
ActiveClaimSummary,
CheckpointNode,
SessionNode,
LineageResponse,
CompareResponse,
StructuredTask,
} from '../types';
import { normalizeTask } from '../types';
import { GitBranch, GitCommit, Loader2, X, ArrowLeft, Zap } from 'lucide-react';
// ── Helpers ───────────────────────────────────────────────────────────────────
@ -624,7 +623,6 @@ export function LineagePage() {
const [lineage, setLineage] = useState<LineageResponse | null>(null);
const [activeClaims, setActiveClaims] = useState<ActiveClaimSummary[]>([]);
const [spaceState, setSpaceState] = useState<import('../types').SpaceStateResponse | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
@ -646,7 +644,6 @@ export function LineagePage() {
])
.then(([lin, state]) => {
setLineage(lin);
setSpaceState(state);
setActiveClaims(state?.active_claims ?? []);
})
.catch(e => setErr(e.message || 'Failed to load lineage'))
@ -752,101 +749,8 @@ export function LineagePage() {
{lineage && !loading && (
<div className="space-y-10">
{/* Current-state summary panel */}
{spaceState && (
<section className="rounded-xl border p-5 space-y-4" style={{ borderColor: 'var(--color-border, #27272a)', background: 'var(--color-surface, #18181b)' }}>
{/* Project header */}
<div>
<h2 className="text-base font-semibold text-white">
{spaceState.space.name}
</h2>
{spaceState.space.description && (
<p className="text-xs text-gray-500 mt-0.5">{spaceState.space.description}</p>
)}
</div>
{/* Current direction */}
{spaceState.commit && (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-600 uppercase tracking-wider">Current direction</span>
</div>
<p className="text-sm text-gray-200">{spaceState.commit.message}</p>
{spaceState.commit.objective && (
<p className="text-xs text-gray-500">{spaceState.commit.objective}</p>
)}
<div className="flex items-center gap-2 text-[10px] text-gray-600">
{spaceState.commit.author_agent && (
<span className="font-mono border border-gray-700 px-1.5 py-px rounded text-gray-500">
{spaceState.commit.author_agent}
</span>
)}
<span>{fmt(spaceState.commit.created_at)}</span>
<code className="text-blue-400">{spaceState.commit.commit_hash?.slice(0, 7)}</code>
</div>
</div>
)}
{/* Status bar */}
<div className="flex items-center gap-4 flex-wrap text-[11px]">
<span className="text-gray-500">
<span className="text-white font-medium">{lineage.checkpoints.length}</span> checkpoint{lineage.checkpoints.length !== 1 ? 's' : ''}
</span>
{(spaceState.active_branches?.length ?? 0) > 0 && (
<span className="text-gray-500">
<span className="text-purple-400 font-medium">{spaceState.active_branches.length}</span> active branch{spaceState.active_branches.length !== 1 ? 'es' : ''}
</span>
)}
{activeClaims.length > 0 && (
<span className="text-gray-500">
<span className="text-amber-400 font-medium">{activeClaims.length}</span> active claim{activeClaims.length !== 1 ? 's' : ''}
</span>
)}
{spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0 && (
<span className="text-red-400 font-medium flex items-center gap-1">
Divergence detected
</span>
)}
{(spaceState.commit?.tasks?.length ?? 0) > 0 && (() => {
const tasks = (spaceState.commit.tasks ?? []).map(normalizeTask);
const openCount = tasks.filter((t: StructuredTask) => !t.status || t.status === 'open').length;
const doneCount = tasks.filter((t: StructuredTask) => t.status === 'done').length;
return (
<span className="text-gray-500">
<span className="text-green-400 font-medium">{openCount}</span> open task{openCount !== 1 ? 's' : ''}
{doneCount > 0 && (
<>, <span className="text-gray-600">{doneCount} done</span></>
)}
</span>
);
})()}
</div>
{/* Needs attention signal */}
{(() => {
const reasons: string[] = [];
if (spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0)
reasons.push('Branch divergence needs resolution');
if (activeClaims.length > 0)
reasons.push(`${activeClaims.length} agent${activeClaims.length !== 1 ? 's' : ''} working — check before starting new work`);
if ((spaceState.commit?.open_questions?.length ?? 0) > 0)
reasons.push(`${spaceState.commit.open_questions.length} open question${spaceState.commit.open_questions.length !== 1 ? 's' : ''} from latest checkpoint`);
if (reasons.length === 0) return null;
return (
<div className="rounded-lg border border-amber-500/30 bg-amber-900/10 px-3 py-2">
<div className="flex items-center gap-2 text-[11px] text-amber-400 font-medium mb-1">
<span> Needs attention</span>
</div>
<ul className="text-[11px] text-amber-300/80 space-y-0.5">
{reasons.map((r, i) => (
<li key={i}> {r}</li>
))}
</ul>
</div>
);
})()}
</section>
)}
{/* Project Current State panel */}
{spaceId && <ProjectCurrentState spaceId={spaceId} />}
{/* Active work claims */}
{activeClaims.length > 0 && (

View file

@ -237,6 +237,7 @@ export interface ActiveClaimSummary {
branch_name: string;
scope: string;
intent_type: string;
task_id?: string | null;
claimed_at: string;
expires_at: string;
base_commit_hash: string | null;
@ -319,3 +320,74 @@ export interface CheckpointReviewResponse {
issues: ReviewIssue[];
suggestions: string[];
}
// ── Project Current State (V5) ────────────────────────────────────────────────
//
// Shape of GET /api/v5/current/spaces/{id} — the packaged "where is this
// project right now" payload. Shared contract with the `smriti current`
// CLI command and the ProjectCurrentState UI panel.
export interface CurrentDirection {
objective: string | null;
headline: string | null;
summary: string | null;
checkpoint_id: string | null;
checkpoint_hash: string | null;
author_agent: string | null;
updated_at: string | null;
}
export interface CurrentCounts {
checkpoints: number;
agents: number;
active_claims: number;
active_branches: number;
open_tasks: number;
milestones: number;
}
export interface AttentionSignal {
kind: 'open_question' | 'divergence' | 'active_work';
severity: 'info' | 'warn';
message: string;
}
export interface MilestoneEntry {
checkpoint_id: string;
checkpoint_hash: string;
checkpoint_message: string;
note: string;
author_agent: string | null;
created_at: string;
}
export interface CurrentTask {
text: string;
id: string | null;
intent_hint: string | null;
blocked_by: string | null;
status: string;
}
export interface RecentActivityEntry {
checkpoint_id: string;
checkpoint_hash: string;
message: string;
author_agent: string | null;
branch_name: string;
created_at: string;
has_milestone: boolean;
}
export interface CurrentState {
space_id: string;
name: string;
description: string | null;
current_direction: CurrentDirection;
counts: CurrentCounts;
attention: AttentionSignal[];
active_work: ActiveClaimSummary[];
recent_milestones: MilestoneEntry[];
open_tasks_by_intent: Record<string, CurrentTask[]>;
recent_activity: RecentActivityEntry[];
}