feat(decisions): add decisions tracking system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-22 16:58:30 -07:00
parent 2b6206aee4
commit a9731b4cd8
9 changed files with 420 additions and 7 deletions

View file

@ -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>

View file

@ -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>

View file

@ -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;
`;

View 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>
);
}

View file

@ -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 });
}

View file

@ -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[];
}

View file

@ -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
View 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
View file

@ -173,7 +173,7 @@ wheels = [
[[package]]
name = "claire"
version = "0.1.4"
version = "0.1.6"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },