feat(decisions): ✨ add decisions tracking system
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
2b6206aee4
commit
a9731b4cd8
9 changed files with 420 additions and 7 deletions
|
|
@ -28,6 +28,7 @@ import { ProjectDetailPage } from "./projects/ProjectDetailPage";
|
|||
import { SessionsPage } from "./sessions/SessionsPage";
|
||||
import { BroadcastPage } from "./broadcast/BroadcastPage";
|
||||
import { DashboardPage } from "./dashboard/DashboardPage";
|
||||
import { DecisionsPage } from "./decisions/DecisionsPage";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -56,6 +57,7 @@ export function App(): ReactElement {
|
|||
<Route path="projects" element={<ProjectsListPage />} />
|
||||
<Route path="projects/:name" element={<ProjectDetailPage />} />
|
||||
<Route path="sessions" element={<SessionsPage />} />
|
||||
<Route path="decisions" element={<DecisionsPage />} />
|
||||
<Route path="broadcast" element={<BroadcastPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export function AppShell(): ReactElement {
|
|||
<NavItem to="/chat">chat</NavItem>
|
||||
<NavItem to="/projects">projects</NavItem>
|
||||
<NavItem to="/sessions">sessions</NavItem>
|
||||
<NavItem to="/decisions">decisions</NavItem>
|
||||
<NavItem to="/broadcast">broadcast</NavItem>
|
||||
</Nav>
|
||||
<Main>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const Pairing = styled.div`
|
|||
`;
|
||||
|
||||
const TaskTitle = styled.span`
|
||||
color: ${({ theme }): string => theme.colors.fg};
|
||||
color: ${({ theme }): string => theme.colors.text.primary};
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
|
@ -38,12 +38,12 @@ const TaskTitle = styled.span`
|
|||
`;
|
||||
|
||||
const Target = styled.span`
|
||||
color: ${({ theme }): string => theme.colors.dim};
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
font-size: 0.78rem;
|
||||
`;
|
||||
|
||||
const BindButton = styled.button`
|
||||
background: ${({ theme }): string => theme.colors.bg};
|
||||
background: ${({ theme }): string => theme.colors.background.primary};
|
||||
border: 1px solid ${({ theme }): string => theme.colors.accent};
|
||||
color: ${({ theme }): string => theme.colors.accent};
|
||||
padding: 0.15rem 0.6rem;
|
||||
|
|
@ -51,7 +51,7 @@ const BindButton = styled.button`
|
|||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
&:hover:not(:disabled) {
|
||||
background: ${({ theme }): string => theme.colors.bg};
|
||||
background: ${({ theme }): string => theme.colors.background.primary};
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
|
|
@ -61,7 +61,7 @@ const BindButton = styled.button`
|
|||
|
||||
const Footer = styled.div`
|
||||
font-size: 0.78rem;
|
||||
color: ${({ theme }): string => theme.colors.dim};
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
border-top: 1px solid ${({ theme }): string => theme.colors.border};
|
||||
padding-top: 0.4rem;
|
||||
`;
|
||||
|
|
|
|||
210
src/claire/web/app/src/decisions/DecisionsPage.tsx
Normal file
210
src/claire/web/app/src/decisions/DecisionsPage.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
/**
|
||||
* Decisions log — newest-first list of non-trivial decisions recorded
|
||||
* during the work, with a badge for WHO made each decision (Claire vs User).
|
||||
*
|
||||
* Backing: GET /api/v1/decisions; rows are appended via the orchestrator
|
||||
* `record_decision` MCP tool (or POST /api/v1/decisions).
|
||||
*/
|
||||
import type { ReactElement } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { ApiError, fetchDecisions } from "../lib/api";
|
||||
import type { Decision, DecisionMaker } from "../lib/types";
|
||||
|
||||
const Wrap = styled.div`
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 60rem;
|
||||
`;
|
||||
|
||||
const H2 = styled.h2`
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-weight: normal;
|
||||
color: ${({ theme }): string => theme.colors.text.primary};
|
||||
`;
|
||||
|
||||
const Sub = styled.p`
|
||||
margin: 0 0 1.25rem 0;
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
font-size: 0.85rem;
|
||||
`;
|
||||
|
||||
const Dim = styled.span`
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
`;
|
||||
|
||||
const ErrorMsg = styled.span`
|
||||
color: ${({ theme }): string => theme.colors.error};
|
||||
`;
|
||||
|
||||
const List = styled.ul`
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
const Row = styled.li`
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid ${({ theme }): string => theme.colors.border};
|
||||
background: ${({ theme }): string => theme.colors.background.secondary};
|
||||
align-items: start;
|
||||
`;
|
||||
|
||||
interface BadgeProps {
|
||||
$maker: DecisionMaker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker badge — Claire decisions ride the existing accent color (cyan-ish
|
||||
* in cyberpunk); User decisions get success (green-ish). Both colors come
|
||||
* from @lilith/ui-theme so we don't introduce a flat-theme regression.
|
||||
*/
|
||||
const Badge = styled.span<BadgeProps>`
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border: 1px solid
|
||||
${({ theme, $maker }): string =>
|
||||
$maker === "claire" ? theme.colors.accent : theme.colors.success};
|
||||
color: ${({ theme, $maker }): string =>
|
||||
$maker === "claire" ? theme.colors.accent : theme.colors.success};
|
||||
background: transparent;
|
||||
white-space: nowrap;
|
||||
align-self: start;
|
||||
`;
|
||||
|
||||
const Body = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
const Text = styled.div`
|
||||
color: ${({ theme }): string => theme.colors.text.primary};
|
||||
line-height: 1.4;
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
const Rationale = styled.div`
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
|
||||
const Links = styled.div`
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.72rem;
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
`;
|
||||
|
||||
const Tag = styled.span`
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
`;
|
||||
|
||||
const When = styled.div`
|
||||
color: ${({ theme }): string => theme.colors.text.muted};
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
align-self: start;
|
||||
`;
|
||||
|
||||
const MAKER_LABEL: Record<DecisionMaker, string> = {
|
||||
claire: "Claire",
|
||||
user: "User",
|
||||
};
|
||||
|
||||
/**
|
||||
* HLC encodes as `<millis>.<counter>.<machine>`. The leading millis is
|
||||
* UTC epoch ms; show "Ns/m/h/d ago" using that. Matches SessionsPage's
|
||||
* `relativeTime` shape — kept inline because it takes a string, not iso.
|
||||
*/
|
||||
function relativeFromHlc(hlc: string): string {
|
||||
const head = hlc.split(".", 1)[0] ?? "";
|
||||
const ms = Number.parseInt(head, 10);
|
||||
if (!Number.isFinite(ms)) return hlc;
|
||||
const diffMs = Date.now() - ms;
|
||||
if (diffMs < 0) return "just now";
|
||||
const sec = Math.round(diffMs / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.round(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.round(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
const days = Math.round(hr / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function shortId(id: string): string {
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
|
||||
export function DecisionsPage(): ReactElement {
|
||||
const decisionsQ = useQuery<Decision[], ApiError>({
|
||||
queryKey: ["decisions"],
|
||||
queryFn: ({ signal }): Promise<Decision[]> => fetchDecisions({}, signal),
|
||||
});
|
||||
|
||||
const decisions = decisionsQ.data ?? [];
|
||||
|
||||
return (
|
||||
<Wrap>
|
||||
<H2>decisions</H2>
|
||||
<Sub>
|
||||
Non-trivial decisions recorded during the work — what was decided,
|
||||
by whom, and why.
|
||||
</Sub>
|
||||
|
||||
{decisionsQ.error ? (
|
||||
<div>
|
||||
<ErrorMsg>{decisionsQ.error.detail}</ErrorMsg>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{decisionsQ.isLoading ? (
|
||||
<Dim>loading…</Dim>
|
||||
) : decisions.length === 0 ? (
|
||||
<Dim>(no decisions recorded yet)</Dim>
|
||||
) : (
|
||||
<List>
|
||||
{decisions.map((d): ReactElement => (
|
||||
<Row key={d.id}>
|
||||
<Badge $maker={d.made_by}>{MAKER_LABEL[d.made_by]}</Badge>
|
||||
<Body>
|
||||
<Text>{d.text}</Text>
|
||||
{d.rationale ? <Rationale>{d.rationale}</Rationale> : null}
|
||||
{(d.project_id || d.task_id) ? (
|
||||
<Links>
|
||||
{d.project_id ? (
|
||||
<span>
|
||||
project <Tag>{shortId(d.project_id)}</Tag>
|
||||
</span>
|
||||
) : null}
|
||||
{d.task_id ? (
|
||||
<span>
|
||||
task <Tag>{shortId(d.task_id)}</Tag>
|
||||
</span>
|
||||
) : null}
|
||||
</Links>
|
||||
) : null}
|
||||
</Body>
|
||||
<When title={d.created_hlc}>{relativeFromHlc(d.created_hlc)}</When>
|
||||
</Row>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,8 @@ import type {
|
|||
ChatPostResponse,
|
||||
ChatScope,
|
||||
ConsideredWork,
|
||||
Decision,
|
||||
DecisionMaker,
|
||||
FleetAgent,
|
||||
FleetLoad,
|
||||
OrgsResponse,
|
||||
|
|
@ -239,3 +241,37 @@ export async function autocomplete(
|
|||
const r = await request<{ hits: AutocompleteHit[] }>("GET", "/api/v1/autocomplete", { params, signal });
|
||||
return r.hits;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decisions log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function fetchDecisions(
|
||||
filters: {
|
||||
project?: string;
|
||||
task?: string;
|
||||
made_by?: DecisionMaker;
|
||||
limit?: number;
|
||||
} = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<Decision[]> {
|
||||
const r = await request<{ decisions: Decision[] }>(
|
||||
"GET",
|
||||
"/api/v1/decisions",
|
||||
{ params: filters, signal },
|
||||
);
|
||||
return r.decisions;
|
||||
}
|
||||
|
||||
export async function recordDecision(
|
||||
body: {
|
||||
made_by: DecisionMaker;
|
||||
text: string;
|
||||
rationale?: string | null;
|
||||
project?: string | null;
|
||||
task_ref?: string | null;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<Decision> {
|
||||
return request<Decision>("POST", "/api/v1/decisions", { body, signal });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -218,3 +218,23 @@ export interface ConsideredWork {
|
|||
remaining_sessions: RemainingSession[];
|
||||
capped_out: CappedHost[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decisions log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DecisionMaker = "claire" | "user";
|
||||
|
||||
export interface Decision {
|
||||
id: string;
|
||||
made_by: DecisionMaker;
|
||||
text: string;
|
||||
rationale: string | null;
|
||||
project_id: string | null;
|
||||
task_id: string | null;
|
||||
created_hlc: string;
|
||||
}
|
||||
|
||||
export interface DecisionsResponse {
|
||||
decisions: Decision[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ def test_migrate_is_idempotent() -> None:
|
|||
assert first == [
|
||||
"0001_initial", "0002_chat", "0003_pm", "0004_fleet",
|
||||
"0003_pm_alter", "0005_session_liveness", "0006_project_org",
|
||||
"0007_usage", "0008_task_blocked_by",
|
||||
"0007_usage", "0008_task_blocked_by", "0009_decisions",
|
||||
]
|
||||
assert second == [] # already applied
|
||||
|
||||
|
|
|
|||
144
tests/test_decisions.py
Normal file
144
tests/test_decisions.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""Service-layer + replay tests for the Decisions log (migration 0009)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from claire.db import migrate, open_db
|
||||
from claire.domain import DecisionMaker
|
||||
from claire.events import replay
|
||||
from claire.hlc import HLCGenerator
|
||||
from claire.web import service
|
||||
|
||||
|
||||
def _setup() -> tuple:
|
||||
conn = open_db(":memory:")
|
||||
migrate(conn)
|
||||
gen = HLCGenerator("test-machine")
|
||||
return conn, gen
|
||||
|
||||
|
||||
def test_record_decision_claire_minimal() -> None:
|
||||
conn, gen = _setup()
|
||||
d = service.record_decision(
|
||||
conn, gen,
|
||||
made_by=DecisionMaker.CLAIRE,
|
||||
text="Use a 2-gate review workflow.",
|
||||
)
|
||||
assert d.made_by is DecisionMaker.CLAIRE
|
||||
assert d.text == "Use a 2-gate review workflow."
|
||||
assert d.rationale is None
|
||||
assert d.project_id is None
|
||||
assert d.task_id is None
|
||||
assert d.created_hlc
|
||||
|
||||
|
||||
def test_record_decision_user_with_rationale_and_links() -> None:
|
||||
conn, gen = _setup()
|
||||
proj = service.create_project(conn, gen, name="p")
|
||||
task = service.add_task(conn, gen, project="p", title="t")
|
||||
d = service.record_decision(
|
||||
conn, gen,
|
||||
made_by=DecisionMaker.USER,
|
||||
text="Defer mobile to phase 2.",
|
||||
rationale="Web app is unblocking more users right now.",
|
||||
project="p",
|
||||
task_ref=str(task.id),
|
||||
)
|
||||
assert d.made_by is DecisionMaker.USER
|
||||
assert d.rationale == "Web app is unblocking more users right now."
|
||||
assert d.project_id == proj.id
|
||||
assert d.task_id == task.id
|
||||
|
||||
|
||||
def test_record_decision_strips_and_rejects_empty_text() -> None:
|
||||
conn, gen = _setup()
|
||||
with pytest.raises(service.InvalidInput):
|
||||
service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.CLAIRE, text=" ",
|
||||
)
|
||||
with pytest.raises(service.InvalidInput):
|
||||
service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.CLAIRE, text="",
|
||||
)
|
||||
|
||||
|
||||
def test_record_decision_unknown_project_raises() -> None:
|
||||
conn, gen = _setup()
|
||||
with pytest.raises(service.NotFound):
|
||||
service.record_decision(
|
||||
conn, gen,
|
||||
made_by=DecisionMaker.CLAIRE, text="x",
|
||||
project="no-such-project",
|
||||
)
|
||||
|
||||
|
||||
def test_record_decision_unknown_task_raises() -> None:
|
||||
conn, gen = _setup()
|
||||
with pytest.raises(service.NotFound):
|
||||
service.record_decision(
|
||||
conn, gen,
|
||||
made_by=DecisionMaker.CLAIRE, text="x",
|
||||
task_ref="00000000-0000-0000-0000-000000000000",
|
||||
)
|
||||
|
||||
|
||||
def test_list_decisions_filters() -> None:
|
||||
conn, gen = _setup()
|
||||
service.create_project(conn, gen, name="pa")
|
||||
service.create_project(conn, gen, name="pb")
|
||||
task_a = service.add_task(conn, gen, project="pa", title="ta")
|
||||
task_b = service.add_task(conn, gen, project="pb", title="tb")
|
||||
|
||||
d1 = service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.CLAIRE,
|
||||
text="claire on pa", project="pa",
|
||||
)
|
||||
d2 = service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.USER,
|
||||
text="user on pa task", project="pa", task_ref=str(task_a.id),
|
||||
)
|
||||
d3 = service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.CLAIRE,
|
||||
text="claire on pb task", project="pb", task_ref=str(task_b.id),
|
||||
)
|
||||
|
||||
# All — newest first.
|
||||
all_ = service.list_decisions(conn)
|
||||
assert [d.id for d in all_] == [d3.id, d2.id, d1.id]
|
||||
|
||||
# Filter by project name → id resolution.
|
||||
proj_a = service.read.get_project(conn, "pa")
|
||||
assert proj_a is not None
|
||||
by_a = service.list_decisions(conn, project_id=proj_a.id)
|
||||
assert {d.id for d in by_a} == {d1.id, d2.id}
|
||||
|
||||
# Filter by task.
|
||||
by_task = service.list_decisions(conn, task_id=task_a.id)
|
||||
assert [d.id for d in by_task] == [d2.id]
|
||||
|
||||
# Filter by made_by.
|
||||
by_user = service.list_decisions(conn, made_by=DecisionMaker.USER)
|
||||
assert [d.id for d in by_user] == [d2.id]
|
||||
by_claire = service.list_decisions(conn, made_by=DecisionMaker.CLAIRE)
|
||||
assert [d.id for d in by_claire] == [d3.id, d1.id]
|
||||
|
||||
|
||||
def test_decision_survives_replay() -> None:
|
||||
conn, gen = _setup()
|
||||
service.create_project(conn, gen, name="p")
|
||||
task = service.add_task(conn, gen, project="p", title="t")
|
||||
d = service.record_decision(
|
||||
conn, gen, made_by=DecisionMaker.CLAIRE,
|
||||
text="design call", rationale="why",
|
||||
project="p", task_ref=str(task.id),
|
||||
)
|
||||
# Wipe + replay event log; the decision row must come back identical.
|
||||
replay(conn)
|
||||
fresh = service.read.get_decision(conn, d.id)
|
||||
assert fresh is not None
|
||||
assert fresh.id == d.id
|
||||
assert fresh.made_by is DecisionMaker.CLAIRE
|
||||
assert fresh.text == "design call"
|
||||
assert fresh.rationale == "why"
|
||||
assert fresh.task_id == task.id
|
||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -173,7 +173,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "claire"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "anthropic" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue