diff --git a/README.md b/README.md index 7c5a34b..b1f0d81 100644 --- a/README.md +++ b/README.md @@ -73,8 +73,8 @@ One project, one Smriti Space, multiple agents. Each reads the state, declares i ## Getting started -You will need: Python 3.11+ and Node 18+. Docker is only needed for -Postgres/shared-team mode. +You will need: Python 3.11+ and Node 20.19+ or Node 22.12+. Docker is +only needed for Postgres/shared-team mode. ### 1. Clone and set up for solo/local mode @@ -146,11 +146,15 @@ This creates the space, installs skill packs for Claude Code and Codex, and conf **MCP config** (Claude Code, Cursor, Windsurf): +`smriti init` prints the exact config for your install. If you configure +MCP manually, activate the Smriti venv first and use the absolute path from +`which smriti-mcp` so the host does not pick up a stale executable. + ```json { "mcpServers": { "smriti": { - "command": "smriti-mcp", + "command": "/absolute/path/to/smriti-mcp", "env": { "SMRITI_API_URL": "http://localhost:8000" } } } diff --git a/backend/app/api/routes/current.py b/backend/app/api/routes/current.py index 11d42ae..de49d80 100644 --- a/backend/app/api/routes/current.py +++ b/backend/app/api/routes/current.py @@ -156,7 +156,7 @@ def _normalize_task(raw: object) -> Optional[CurrentTask]: if not text: return None status = raw.get("status") or "open" - intent = raw.get("intent_hint") + intent = raw.get("intent_hint") or raw.get("intent_type") return CurrentTask( text=text, id=(raw.get("id") or None), diff --git a/backend/app/api/routes/metrics.py b/backend/app/api/routes/metrics.py index f585860..b4a6ce0 100644 --- a/backend/app/api/routes/metrics.py +++ b/backend/app/api/routes/metrics.py @@ -171,9 +171,12 @@ def get_space_metrics(space_id: uuid.UUID, db: Session = Depends(get_db)): total_decisions += len(decisions) total_tasks += len(tasks) - # Structured tasks: at least one task is a dict with intent_hint + # Structured tasks: at least one task is a dict-shaped task entry. + # Intent hints are useful, but task IDs without intent hints are still + # structured tasks and should not make the human-facing metric look + # contradictory. has_structured = any( - isinstance(t, dict) and t.get("intent_hint") + isinstance(t, dict) and str(t.get("text") or "").strip() for t in tasks ) if has_structured: diff --git a/backend/tests/integration/test_current_state.py b/backend/tests/integration/test_current_state.py index bff62a4..244a464 100644 --- a/backend/tests/integration/test_current_state.py +++ b/backend/tests/integration/test_current_state.py @@ -176,6 +176,31 @@ def test_current_open_tasks_grouped_by_intent(client): assert cur["counts"]["open_tasks"] == 3 # done task excluded +def test_current_open_tasks_accept_legacy_intent_type(client): + """Demo data seeded before intent_hint should still group cleanly.""" + repo_id = _create_repo(client, "Legacy Task Intent") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Has legacy task intent", + tasks=[ + { + "text": "Implement middleware", + "intent_type": "implement", + "id": "middleware", + "status": "open", + }, + ], + ) + + cur = _get_current(client, repo_id) + + assert set(cur["open_tasks_by_intent"].keys()) == {"implement"} + assert cur["open_tasks_by_intent"]["implement"][0]["id"] == "middleware" + + def test_current_recent_milestones(client): """Milestone notes surface in recent_milestones; plain notes do not.""" repo_id = _create_repo(client, "Milestones") diff --git a/backend/tests/integration/test_metrics.py b/backend/tests/integration/test_metrics.py index 5582397..86f846f 100644 --- a/backend/tests/integration/test_metrics.py +++ b/backend/tests/integration/test_metrics.py @@ -143,12 +143,14 @@ def test_metrics_structured_tasks_and_ids(client): tasks=[{"text": "Task", "intent_hint": "implement"}]) _commit(client, repo_id, sid, "with ids", tasks=[{"text": "Task", "id": "t1", "intent_hint": "test"}]) + _commit(client, repo_id, sid, "with id but no intent", + tasks=[{"text": "Task", "id": "t2"}]) r = client.get(f"/api/v5/metrics/spaces/{repo_id}") data = r.json() - assert data["state_quality"]["checkpoints_with_structured_tasks"] == 2 - assert data["state_quality"]["checkpoints_with_task_ids"] == 1 + assert data["state_quality"]["checkpoints_with_structured_tasks"] == 3 + assert data["state_quality"]["checkpoints_with_task_ids"] == 2 def test_metrics_response_shape(client): diff --git a/cli/README.md b/cli/README.md index 32e18a7..3749e4b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -36,11 +36,15 @@ Run Smriti as a local MCP server so agents inside Claude Code, Cursor, or Windsu **Claude Code config** (typically `~/.config/claude-code/mcp.json` or `~/Library/Application Support/Claude/claude_desktop_config.json` — check your host's docs for the exact path): +`smriti init ` prints the recommended MCP config for the current +install. If you configure MCP manually, use the absolute path from +`which smriti-mcp` after activating the intended environment. + ```json { "mcpServers": { "smriti": { - "command": "smriti-mcp", + "command": "/absolute/path/to/smriti-mcp", "env": { "SMRITI_API_URL": "http://localhost:8000" } } } diff --git a/cli/smriti_cli/formatters.py b/cli/smriti_cli/formatters.py index 70a37e4..33b4d3a 100644 --- a/cli/smriti_cli/formatters.py +++ b/cli/smriti_cli/formatters.py @@ -128,7 +128,10 @@ def _normalize_task_item(item) -> dict: if isinstance(item, str): return {"text": item} if isinstance(item, dict) and item.get("text"): - return item + task = dict(item) + if not task.get("intent_hint") and task.get("intent_type"): + task["intent_hint"] = task["intent_type"] + return task # Fallback: coerce to string return {"text": str(item)} @@ -499,7 +502,7 @@ def _direction_text(value) -> str: if isinstance(value, str): return value.strip() if isinstance(value, dict): - for key in ("text", "summary", "objective", "message"): + for key in ("text", "objective", "headline", "summary", "message"): raw = value.get(key) if isinstance(raw, str) and raw.strip(): return raw.strip() @@ -867,7 +870,10 @@ def format_metrics(data: dict) -> str: agent_dist = coord.get("agent_checkpoints", {}) dist_str = ", ".join(f"{a}: {n}" for a, n in sorted(agent_dist.items())) parts.append("## Coordination") - parts.append(f"{total} checkpoints · {agents} agent{'s' if agents != 1 else ''} ({dist_str})") + agent_summary = f"{total} checkpoints · {agents} agent{'s' if agents != 1 else ''}" + if dist_str: + agent_summary += f" ({dist_str})" + parts.append(agent_summary) cross = coord.get("cross_agent_continuations", 0) parts.append(f"{cross} cross-agent continuation{'s' if cross != 1 else ''}") @@ -889,7 +895,12 @@ def format_metrics(data: dict) -> str: noise = sq.get("noise_count", 0) parts.append("## State quality") parts.append(f"{avg_d} decisions/checkpoint · {avg_t} tasks/checkpoint") - parts.append(f"{structured} with structured tasks · {with_ids} with task IDs") + structured_label = "checkpoint" if structured == 1 else "checkpoints" + ids_label = "checkpoint" if with_ids == 1 else "checkpoints" + parts.append( + f"{structured} {structured_label} with structured tasks · " + f"{with_ids} {ids_label} with task IDs" + ) parts.append(f"{milestones} milestone{'s' if milestones != 1 else ''} · {noise} noise label{'s' if noise != 1 else ''}") parts.append("") diff --git a/cli/smriti_cli/quickstart.py b/cli/smriti_cli/quickstart.py index 70d4468..a42eed0 100644 --- a/cli/smriti_cli/quickstart.py +++ b/cli/smriti_cli/quickstart.py @@ -78,9 +78,9 @@ _ASSUME_SINGLE_INSTANCE = "The API runs as a single instance for now" _ASSUME_MULTI_INSTANCE = "The API will scale to multiple instances within two quarters" -def _task(task_id: str, text: str, intent_type: str, status: str) -> dict: +def _task(task_id: str, text: str, intent_hint: str, status: str) -> dict: """A structured task entry — the shape the extractor and `state` expect.""" - return {"id": task_id, "text": text, "intent_type": intent_type, "status": status} + return {"id": task_id, "text": text, "intent_hint": intent_hint, "status": status} # ── Main-branch checkpoints ───────────────────────────────────────────────── @@ -109,8 +109,8 @@ MAIN_CHECKPOINTS: list[dict] = [ "Do we limit per client IP, or per API key?", ], "tasks": [ - _task("survey", "Survey rate-limiting algorithms and pick candidates", "explore", "open"), - _task("decide", "Decide the algorithm and what the limit is keyed on", "decide", "open"), + _task("survey", "Survey rate-limiting algorithms and pick candidates", "investigate", "open"), + _task("decide", "Decide the algorithm and what the limit is keyed on", "investigate", "open"), _task("middleware", "Build the rate-limit middleware", "implement", "open"), ], "entities": ["public API", "API gateway", "rate limiter"], @@ -132,8 +132,8 @@ MAIN_CHECKPOINTS: list[dict] = [ "assumptions": [_ASSUME_BURST, _ASSUME_SINGLE_INSTANCE], "open_questions": [], "tasks": [ - _task("survey", "Survey rate-limiting algorithms and pick candidates", "explore", "done"), - _task("decide", "Decide the algorithm and what the limit is keyed on", "decide", "done"), + _task("survey", "Survey rate-limiting algorithms and pick candidates", "investigate", "done"), + _task("decide", "Decide the algorithm and what the limit is keyed on", "investigate", "done"), _task("middleware", "Build the rate-limit middleware", "implement", "open"), _task("loadtest", "Load-test burst and sustained traffic", "test", "open"), ], @@ -169,8 +169,8 @@ MAIN_CHECKPOINTS: list[dict] = [ "assumptions": [_ASSUME_BURST, _ASSUME_SINGLE_INSTANCE], "open_questions": [], "tasks": [ - _task("survey", "Survey rate-limiting algorithms and pick candidates", "explore", "done"), - _task("decide", "Decide the algorithm and what the limit is keyed on", "decide", "done"), + _task("survey", "Survey rate-limiting algorithms and pick candidates", "investigate", "done"), + _task("decide", "Decide the algorithm and what the limit is keyed on", "investigate", "done"), _task("middleware", "Build the rate-limit middleware", "implement", "done"), _task("loadtest", "Load-test burst and sustained traffic", "test", "open"), ], @@ -217,8 +217,8 @@ MAIN_CHECKPOINTS: list[dict] = [ "assumptions": [_ASSUME_BURST, _ASSUME_SINGLE_INSTANCE], "open_questions": [], "tasks": [ - _task("survey", "Survey rate-limiting algorithms and pick candidates", "explore", "done"), - _task("decide", "Decide the algorithm and what the limit is keyed on", "decide", "done"), + _task("survey", "Survey rate-limiting algorithms and pick candidates", "investigate", "done"), + _task("decide", "Decide the algorithm and what the limit is keyed on", "investigate", "done"), _task("middleware", "Build the rate-limit middleware", "implement", "done"), _task("loadtest", "Load-test burst and sustained traffic", "test", "done"), _task("document", "Document the 429 / Retry-After contract for API consumers", "docs", "open"), @@ -259,9 +259,9 @@ FORK_CHECKPOINT: dict = { "assumptions": [_ASSUME_BURST, _ASSUME_MULTI_INSTANCE], "open_questions": ["Is ~3ms of added per-request latency acceptable at the edge?"], "tasks": [ - _task("survey", "Survey rate-limiting algorithms and pick candidates", "explore", "done"), - _task("decide", "Decide the algorithm and what the limit is keyed on", "decide", "done"), - _task("redis-spike", "Prototype the Redis token-bucket store", "explore", "done"), + _task("survey", "Survey rate-limiting algorithms and pick candidates", "investigate", "done"), + _task("decide", "Decide the algorithm and what the limit is keyed on", "investigate", "done"), + _task("redis-spike", "Prototype the Redis token-bucket store", "investigate", "done"), ], "entities": ["rate limiter", "token bucket", "Redis", "distributed rate limiter"], "artifacts": [], diff --git a/cli/tests/test_current_cli.py b/cli/tests/test_current_cli.py index 45f838f..62ec095 100644 --- a/cli/tests/test_current_cli.py +++ b/cli/tests/test_current_cli.py @@ -146,6 +146,20 @@ def test_format_project_current_renders_contract_sections(): assert "## Recent activity" in out +def test_format_project_current_prefers_objective_for_direction(): + payload = _current_payload() + payload["current_direction"] = { + "objective": "Add per-client rate limiting before launch.", + "headline": "Load test passes", + "summary": "Long backend-provided summary that may be preview-clipped.", + } + + out = format_project_current(payload) + + assert "Add per-client rate limiting before launch." in out + assert "Long backend-provided summary" not in out + + def test_cmd_current_prefers_backend_payload(capsys: pytest.CaptureFixture[str]): client = MagicMock(spec=SmritiClient) client.resolve_space.return_value = _space() @@ -238,4 +252,3 @@ def test_cmd_current_falls_back_to_shipped_endpoints(capsys: pytest.CaptureFixtu client.get_space_state.assert_called_once_with("space-uuid") client.list_commits.assert_called_once_with("space-uuid") client.get_space_metrics.assert_called_once_with("space-uuid") - diff --git a/cli/tests/test_metrics_cli.py b/cli/tests/test_metrics_cli.py new file mode 100644 index 0000000..0a1b982 --- /dev/null +++ b/cli/tests/test_metrics_cli.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from smriti_cli.formatters import format_metrics + + +def test_format_metrics_omits_empty_agent_distribution(): + out = format_metrics({ + "space_name": "empty", + "coordination": { + "total_checkpoints": 0, + "unique_agents": 0, + "agent_checkpoints": {}, + }, + "state_quality": {}, + "branches": {}, + }) + + assert "0 checkpoints · 0 agents\n" in out + assert "0 agents ()" not in out + + +def test_format_metrics_names_checkpoint_task_counts(): + out = format_metrics({ + "space_name": "demo", + "coordination": {}, + "state_quality": { + "checkpoints_with_structured_tasks": 5, + "checkpoints_with_task_ids": 5, + }, + "branches": {}, + }) + + assert "5 checkpoints with structured tasks · 5 checkpoints with task IDs" in out diff --git a/cli/tests/test_quickstart.py b/cli/tests/test_quickstart.py index d69d4e4..0d09976 100644 --- a/cli/tests/test_quickstart.py +++ b/cli/tests/test_quickstart.py @@ -150,6 +150,12 @@ def test_fixture_claim_intent_types_are_backend_valid(): assert claim["intent_type"] in BACKEND_INTENT_TYPES +def test_fixture_task_intent_hints_are_backend_valid(): + for cp in [*MAIN_CHECKPOINTS, FORK_CHECKPOINT]: + for task in cp["tasks"]: + assert task["intent_hint"] in BACKEND_INTENT_TYPES + + def test_fixture_has_one_done_and_one_active_claim(): statuses = {claim["final_status"] for claim in CLAIMS} assert statuses == {"done", "active"} @@ -180,7 +186,7 @@ def test_fixture_branch_diverges_from_its_fork_source(): def test_fixture_tasks_are_well_formed(): for cp in [*MAIN_CHECKPOINTS, FORK_CHECKPOINT]: for task in cp["tasks"]: - assert {"id", "text", "intent_type", "status"} <= set(task) + assert {"id", "text", "intent_hint", "status"} <= set(task) assert task["status"] in {"open", "done"} diff --git a/cli/tests/test_state_multi_branch.py b/cli/tests/test_state_multi_branch.py index 88d7cb5..303daf6 100644 --- a/cli/tests/test_state_multi_branch.py +++ b/cli/tests/test_state_multi_branch.py @@ -529,6 +529,15 @@ def test_task_section_structured_with_intent(): assert "- Add freshness tests [test]" in out +def test_task_section_accepts_legacy_intent_type(): + """Older demo/checkpoint data with intent_type still renders as structured.""" + tasks = [ + {"text": "Update docs", "intent_type": "docs"}, + ] + out = _task_section(tasks) + assert "- Update docs [docs]" in out + + def test_task_section_structured_with_blocked_by(): """Structured task with blocked_by renders inline marker.""" tasks = [ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 514ade2..00db4ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,9 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/code-frame": { diff --git a/frontend/package.json b/frontend/package.json index 4e4db58..e3ddd0c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.0.0", "type": "module", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, "scripts": { "dev": "vite", "build": "tsc -b && vite build",