Compare commits

...

58 Commits

Author SHA1 Message Date
5c124458f3 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-30:
  - update .custodian-brief.md for the-custodian
2026-03-30 00:48:48 +02:00
5399fcec20 fix(dashboard): merge token + event charts into single dual-axis chart on Progress page
Replaces the two separate charts with one combined area+line chart.
Events use the left y-axis (steelblue); tokens use a normalized scale
with a right y-axis (amber) that formats values as k/M. When no token
data exists yet the right axis is omitted and a legend note explains.
Hover tooltips show actual values for both series.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:48:22 +02:00
8875057984 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-30:
  - update .custodian-brief.md for the-custodian
2026-03-30 00:42:38 +02:00
b54ee8149c feat(dashboard): add tokens consumed per day chart to Progress page
Fetches /token-events/?limit=1000 in parallel with progress events and
renders a second area+line chart (amber) below the events-per-day chart,
aggregating tokens_in + tokens_out per calendar day over the same 30-day window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:42:09 +02:00
5fbce5ef6f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-30:
  - update .custodian-brief.md for the-custodian
2026-03-30 00:02:40 +02:00
f5c166e77e feat(dashboard): add repo filter, sort order, and max results controls to Token Cost page
Three reactive dropdowns below the Token Cost heading:
- Filter by repo: client-side filter via 3-level chain resolution
- Sort by: Tokens Total (default), Tokens In, Out, Event Count, Most Recent
- Show: 10/20/50/100/500 rows per table (default 20)

Applies uniformly to By Repo, By Workplan, and Top Tasks tables.
"Most Recent" derives last_event_at per group from the fetched events.
Truncated tables show a "Showing M of N" count below.

Completes CUST-WP-0030 T07–T09.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:02:17 +02:00
4fd31d4a6d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:59:59 +02:00
a4563dec14 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:59:24 +02:00
fc5a55a27a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T09: todo → done
2026-03-29 23:59:22 +02:00
4b79c243dc chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T08: todo → done
2026-03-29 23:59:22 +02:00
6744ad5f74 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T07: todo → done
2026-03-29 23:59:22 +02:00
0de8c17bc7 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:41:06 +02:00
bf8acd0bed chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:40:32 +02:00
697da3825b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:39:53 +02:00
81022533b5 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:38:55 +02:00
d403a3ed52 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:37:56 +02:00
d350293e6f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:20:45 +02:00
ba516bb46c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 23:06:09 +02:00
86548a4dea chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 22:51:38 +02:00
93a55ac4e2 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 22:37:02 +02:00
a32fe1166c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 22:36:04 +02:00
20ce332d55 feat(dashboard): entity list UX — REF column, name cells, detail pages (CUST-WP-0030)
- ref-cell.js: REF column component — click=copy deeplink, dblclick=open
- field-help.js: field registry + fieldRow helper with help-tip decoration;
  FK fields (task_id, workstream_id, repo_id) render as async-linked cells
  with entity-title bubble-help on hover
- GET /token-events/{id} endpoint + get-by-id tests
- GET /repos/by-id/{repo_id} UUID lookup endpoint
- Landing pages: /token-events/[id], /workstreams/[id], /repos/[slug], /tasks/[id]
- token-cost.md: REF + Name columns on all three tables; parallel fetch of
  workstreams/tasks for title resolution
- reference.md: entity detail page URL scheme documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 22:35:35 +02:00
8a2e786111 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 22:22:18 +02:00
055c973be0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 22:07:43 +02:00
2f7299ebc8 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 21:53:13 +02:00
00d5bcb919 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 21:38:35 +02:00
f2b847450f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 21:24:18 +02:00
d9ba1ec7cb chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 21:09:15 +02:00
61fa59e6e6 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:54:39 +02:00
c54f8eb966 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:40:08 +02:00
3428a680c3 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:38:01 +02:00
3e54cb05c8 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T06: todo → done
2026-03-29 20:38:00 +02:00
3d64f288d9 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:25:22 +02:00
ac91e5905c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:10:52 +02:00
490fdc52d4 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 20:09:54 +02:00
1d4c4e31d9 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:56:00 +02:00
943be6fb97 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T05: todo → done
2026-03-29 19:55:58 +02:00
99a8ba66e9 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T04: todo → done
2026-03-29 19:55:58 +02:00
94aee3d845 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T03: todo → done
2026-03-29 19:55:58 +02:00
df84198c2e chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T02: todo → done
2026-03-29 19:55:58 +02:00
b36e7d469c chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - T01: todo → done
2026-03-29 19:55:58 +02:00
b0edfbb06f chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:41:23 +02:00
99cf8c8386 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:27:19 +02:00
5594f15590 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:26:40 +02:00
a728d04eae chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:11:52 +02:00
c763df3805 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 19:05:45 +02:00
bb965e4990 feat(token-tracking): repo aggregation via graph walk (task→workstream→repo)
By Repo now resolves via the full chain rather than requiring repo_id
directly on the token event:
  1. token_events.repo_id (direct)
  2. → workstreams.repo_id (via workstream_id)
  3. → task.workstream_id → workstreams.repo_id (via task_id)

Changes:
- Auto-populate repo_id on token events at creation time (both the
  token_events router and the tasks router)
- New GET /token-events/by-repo/ endpoint with RepoTokenSummary schema;
  returns tokens_in/out/total, event_count, by_model, by_note per repo
- Dashboard By Repo section uses /by-repo/ directly and shows repo_slug
  instead of a truncated UUID
- Backfilled the three existing events (userbased) with repo_id via SQL

185 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 19:05:23 +02:00
18b73f1e2b chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:57:46 +02:00
e19953f0b1 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:48:10 +02:00
68943684e1 feat(token-tracking): introduce token note taxonomy (measured/userbased/workplan/heuristic)
Tier 1 (exact counts) now defaults to note="measured" instead of null,
signalling the counts were read from the Claude Code status bar.
Callers can pass note="userbased" when a human provided the numbers.

  measured  — agent read exact counts from the Claude Code status bar
  userbased — counts provided by a human
  workplan  — prorated from workplan total across task count
  heuristic — server fallback, 1000/500, no agent input

Added token_note field to TaskUpdate schema and exposed note param on
update_task_status and record_interactive_task MCP tools.
TOOLS.md documents the full taxonomy. 185 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:47:40 +02:00
48e0dfc0ca chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:42:25 +02:00
e5c2ab4114 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:37:18 +02:00
edffc62775 feat(token-tracking): add record_interactive_task MCP tool
New tool for capturing ad-hoc work done outside formal workplans.
Finds or creates a persistent 'interactive-<repo>' workstream for the
repo, creates the task, marks it done, and records a token event using
the three-tier logic — all in a single call.

Seeded two example events on interactive-the-custodian:
  - Three-tier token recording on task done (8000/3500)
  - Add record_interactive_task MCP tool (4500/1800)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:36:51 +02:00
c0eec6e168 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:28:48 +02:00
e247c20439 feat(token-tracking): three-tier token recording on task done
Token events are now always created when update_task_status is called
with status="done", using the best available data:

  Tier 1 (best): exact tokens_in + tokens_out passed by agent
  Tier 2:        workplan_tokens_in + workplan_tokens_out prorated
                 across workstream task count (note="workplan")
  Tier 3 (fallback): heuristic 1000 in / 500 out (note="heuristic")

Non-done status changes never create a token event.
MCP tool updated with workplan_tokens_in/out params and tiered docs.
Ralph-workplan skill files updated with the three-tier guidance.
184 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:28:18 +02:00
3de29b5a9a chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:28:01 +02:00
c0f0e312c0 chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 18:12:56 +02:00
174546699e chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-03-29:
  - update .custodian-brief.md for the-custodian
2026-03-29 17:58:09 +02:00
23 changed files with 1661 additions and 157 deletions

View File

@@ -1,14 +1,14 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — the-custodian
**Domain:** railiance
**Last synced:** 2026-03-29 15:47 UTC
**Domain:** custodian
**Last synced:** 2026-03-29 22:48 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Cross-Repo E2E Sandbox Framework
Progress: 8/8 done | workstream_id: `b68de20b-e397-4f97-b1be-ad30711fc2a6`
### Interactive — the-custodian
Progress: 4/4 done | workstream_id: `370c2481-6806-41eb-a917-f8874f03184f`
### FOS Hub Bootstrap — Identity, Hub Extraction, Ops Hub, Fin Hub
Progress: 0/26 done | workstream_id: `293a74fe-a85a-4ad6-8933-23d52a72fe8b`
@@ -64,6 +64,6 @@ Progress: 0/9 done | workstream_id: `9cc32158-2f5c-4ef6-9713-aacce4623d5e`
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("railiance")`
`get_domain_summary("custodian")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

View File

@@ -326,6 +326,17 @@ async def get_repo_doi(
)
@router.get("/by-id/{repo_id}", response_model=RepoRead)
async def get_repo_by_id(
repo_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> ManagedRepo:
repo = await session.get(ManagedRepo, repo_id)
if repo is None:
raise HTTPException(status_code=404, detail=f"Repo '{repo_id}' not found")
return repo
@router.get("/{slug}/", response_model=RepoRead)
async def get_repo(
slug: str,

View File

@@ -2,12 +2,13 @@ import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
router = APIRouter(prefix="/tasks", tags=["tasks"])
@@ -75,27 +76,51 @@ async def update_task(
raise HTTPException(status_code=404, detail="Task not found")
# Separate token fields from task fields
token_fields = {"tokens_in", "tokens_out", "model", "agent", "session_id"}
token_field_names = {"tokens_in", "tokens_out", "workplan_tokens_in", "workplan_tokens_out", "token_note", "model", "agent", "session_id"}
update_data = body.model_dump(exclude_unset=True)
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_fields}
token_data = {k: update_data.pop(k) for k in list(update_data.keys()) if k in token_field_names}
for field, value in update_data.items():
setattr(task, field, value)
await session.commit()
await session.refresh(task)
# Create token event if token passthrough fields provided
if "tokens_in" in token_data and "tokens_out" in token_data:
# Token event — three-tier logic, only when marking done
if update_data.get("status") == "done":
if "tokens_in" in token_data and "tokens_out" in token_data:
# Tier 1: exact counts — default note "measured"; caller may override with token_note
tin = token_data["tokens_in"]
tout = token_data["tokens_out"]
tnote = token_data.get("token_note") or "measured"
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
# Tier 2: prorate workplan total across task count
count_result = await session.execute(
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
)
task_count = max(count_result.scalar() or 1, 1)
tin = token_data["workplan_tokens_in"] // task_count
tout = token_data["workplan_tokens_out"] // task_count
tnote = "workplan"
else:
# Tier 3: heuristic fallback
tin, tout, tnote = 1000, 500, "heuristic"
# Resolve repo_id via workstream
ws = await session.get(Workstream, task.workstream_id)
repo_id = ws.repo_id if ws else None
event = TokenEvent(
task_id=task_id,
workstream_id=task.workstream_id,
tokens_in=token_data["tokens_in"],
tokens_out=token_data["tokens_out"],
repo_id=repo_id,
tokens_in=tin,
tokens_out=tout,
model=token_data.get("model"),
agent=token_data.get("agent"),
session_id=token_data.get("session_id"),
ref_type="task",
ref_id=str(task_id),
note=tnote,
)
session.add(event)
await session.commit()

View File

@@ -6,9 +6,11 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.database import get_session
from api.models.managed_repo import ManagedRepo
from api.models.task import Task
from api.models.token_event import TokenEvent
from api.schemas.token_event import TokenEventCreate, TokenEventRead, TokenSummary
from api.models.workstream import Workstream
from api.schemas.token_event import RepoTokenSummary, TokenEventCreate, TokenEventRead, TokenSummary
router = APIRouter(prefix="/token-events", tags=["token-events"])
@@ -26,6 +28,12 @@ async def create_token_event(
if task:
data["workstream_id"] = task.workstream_id
# Auto-populate repo_id from workstream if not provided
if data.get("workstream_id") and not data.get("repo_id"):
ws = await session.get(Workstream, data["workstream_id"])
if ws and ws.repo_id:
data["repo_id"] = ws.repo_id
event = TokenEvent(**data)
session.add(event)
await session.commit()
@@ -90,6 +98,85 @@ async def get_token_summary(
)
@router.get("/by-repo/", response_model=list[RepoTokenSummary])
async def get_tokens_by_repo(
session: AsyncSession = Depends(get_session),
) -> list[RepoTokenSummary]:
"""Aggregate token consumption per repo, resolving via the full graph.
Resolution order for each event:
1. token_events.repo_id (direct)
2. → workstreams.repo_id (via workstream_id)
3. → task.workstream_id → workstreams.repo_id (via task_id)
Only events that resolve to a repo are included.
"""
# Fetch all events, workstreams, repos in three queries (avoids N+1)
events_result = await session.execute(select(TokenEvent))
events = list(events_result.scalars().all())
ws_result = await session.execute(select(Workstream))
ws_map: dict[uuid.UUID, Workstream] = {w.id: w for w in ws_result.scalars().all()}
task_result = await session.execute(select(Task))
task_map: dict[uuid.UUID, Task] = {t.id: t for t in task_result.scalars().all()}
repo_result = await session.execute(select(ManagedRepo))
repo_map: dict[uuid.UUID, ManagedRepo] = {r.id: r for r in repo_result.scalars().all()}
def resolve_repo_id(e: TokenEvent) -> uuid.UUID | None:
if e.repo_id:
return e.repo_id
ws_id = e.workstream_id
if not ws_id and e.task_id and e.task_id in task_map:
ws_id = task_map[e.task_id].workstream_id
if ws_id and ws_id in ws_map:
return ws_map[ws_id].repo_id
return None
groups: dict[uuid.UUID, dict] = {}
for e in events:
rid = resolve_repo_id(e)
if not rid or rid not in repo_map:
continue
if rid not in groups:
groups[rid] = {
"repo_id": rid,
"repo_slug": repo_map[rid].slug,
"tokens_in": 0,
"tokens_out": 0,
"event_count": 0,
"by_model": defaultdict(int),
"by_note": defaultdict(int),
}
g = groups[rid]
g["tokens_in"] += e.tokens_in
g["tokens_out"] += e.tokens_out
g["event_count"] += 1
if e.model:
g["by_model"][e.model] += e.tokens_in + e.tokens_out
g["by_note"][e.note or "unknown"] += e.tokens_in + e.tokens_out
return [
RepoTokenSummary(
**{k: (dict(v) if isinstance(v, defaultdict) else v) for k, v in g.items()},
tokens_total=g["tokens_in"] + g["tokens_out"],
)
for g in sorted(groups.values(), key=lambda x: -(x["tokens_in"] + x["tokens_out"]))
]
@router.get("/{event_id}", response_model=TokenEventRead)
async def get_token_event(
event_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
) -> TokenEvent:
event = await session.get(TokenEvent, event_id)
if event is None:
raise HTTPException(status_code=404, detail="Token event not found")
return event
@router.get("/", response_model=list[TokenEventRead])
async def list_token_events(
task_id: uuid.UUID | None = None,

View File

@@ -38,9 +38,16 @@ class TaskUpdate(BaseModel):
needs_human: bool | None = None
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
# Optional token passthrough — when provided, a token_event is created
# Token passthrough — three tiers (highest precision wins):
# 1. tokens_in + tokens_out → exact counts; note defaults to "measured"
# 2. workplan_tokens_in + workplan_tokens_out → prorated across task count (note="workplan")
# 3. neither provided, status=done → heuristic 1000/500 (note="heuristic")
# token_note overrides the auto-assigned note for Tier 1 only (e.g. "userbased")
tokens_in: int | None = None
tokens_out: int | None = None
workplan_tokens_in: int | None = None
workplan_tokens_out: int | None = None
token_note: str | None = None
model: str | None = None
agent: str | None = None
session_id: str | None = None

View File

@@ -50,3 +50,14 @@ class TokenSummary(BaseModel):
event_count: int
by_model: dict[str, int]
by_agent: dict[str, int]
class RepoTokenSummary(BaseModel):
repo_id: uuid.UUID
repo_slug: str
tokens_in: int
tokens_out: int
tokens_total: int
event_count: int
by_model: dict[str, int]
by_note: dict[str, int]

View File

@@ -0,0 +1,263 @@
// Field-level help registry for dashboard entity detail pages.
//
// FIELD_HELP maps a field key to { label, description, doc? }.
// label — human-readable field name (used as bold heading in help-tip)
// description — one sentence explaining the field
// doc — optional anchor into /docs/ pages for "Learn more"
//
// fieldRow(key, value) → <tr> element with a help-tip-decorated key cell and
// a value cell. Falls back to a plain key cell when key is not in FIELD_HELP.
//
// Usage:
// import {fieldRow} from "./components/field-help.js";
// const tbody = html`<tbody>${fields.map(([k,v]) => fieldRow(k,v))}</tbody>`;
import {HelpTip} from "./help-tip.js"; // ensures custom element is registered
import {API} from "./config.js";
void HelpTip; // silence unused-import linters
// ── Entity link registry ────────────────────────────────────────────────────
// Maps FK field names to fetch/URL/title resolution rules.
// getUrl receives (id, data) so slug-routed entities (repos) can use data.slug.
const FIELD_LINKS = {
task_id: {
apiUrl: id => `${API}/tasks/${id}`,
getUrl: (id, _d) => `/tasks/${id}`,
getTitle: d => d.title,
},
workstream_id: {
apiUrl: id => `${API}/workstreams/${id}`,
getUrl: (id, _d) => `/workstreams/${id}`,
getTitle: d => d.title || d.slug,
},
repo_id: {
apiUrl: id => `${API}/repos/by-id/${id}`,
getUrl: (_id, d) => `/repos/${d.slug}`,
getTitle: d => d.name || d.slug,
},
};
/**
* Render an entity-reference value as a link with an async help-tip showing
* the entity title. Falls back gracefully if the fetch fails.
*/
function _linkCell(key, id) {
const rule = FIELD_LINKS[key];
const shortId = String(id).slice(0, 8) + "…";
const a = document.createElement("a");
a.textContent = shortId;
a.href = "#";
a.style.fontFamily = "var(--monospace, monospace)";
a.title = String(id); // full UUID as native tooltip while async loads
fetch(rule.apiUrl(id))
.then(r => r.ok ? r.json() : null)
.then(data => {
if (!data) return;
const title = rule.getTitle(data);
const url = rule.getUrl(id, data);
if (url) a.href = url;
if (title) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", title);
tip.setAttribute("description", `${key.replace(/_id$/, "")} · ${id}`);
a.replaceWith(tip);
tip.appendChild(a);
}
})
.catch(() => { /* leave plain link */ });
return a;
}
export const FIELD_HELP = {
// ── TokenEvent ─────────────────────────────────────────────────────────────
id: {
label: "ID",
description: "Unique identifier for this token event (UUID v4).",
doc: "/docs/reference#token-events",
},
tokens_in: {
label: "Tokens In",
description: "Number of input (prompt) tokens consumed in this event.",
doc: "/docs/reference#token-events",
},
tokens_out: {
label: "Tokens Out",
description: "Number of output (completion) tokens generated in this event.",
doc: "/docs/reference#token-events",
},
tokens_total: {
label: "Tokens Total",
description: "Sum of input and output tokens — total cost proxy for this event.",
doc: "/docs/reference#token-events",
},
task_id: {
label: "Task ID",
description: "The task this token event was recorded against (if any).",
doc: "/docs/tasks",
},
workstream_id: {
label: "Workstream ID",
description: "The workstream this event belongs to; auto-resolved from task if not set directly.",
doc: "/docs/workstreams",
},
repo_id: {
label: "Repo ID",
description: "The managed repo this event is attributed to; auto-resolved from workstream.",
doc: "/docs/repos",
},
session_id: {
label: "Session ID",
description: "Opaque identifier for the Claude Code session that produced this event.",
},
model: {
label: "Model",
description: "The Claude model used (e.g. claude-sonnet-4-6).",
doc: "/docs/reference#models",
},
agent: {
label: "Agent",
description: "The agent persona that produced this event (e.g. custodian, ralph).",
doc: "/docs/reference#agents",
},
ref_type: {
label: "Ref Type",
description: "What kind of entity the ref_id points to: task, commit, release, or session.",
doc: "/docs/reference#token-events",
},
ref_id: {
label: "Ref ID",
description: "Free-form reference ID; interpretation depends on ref_type.",
doc: "/docs/reference#token-events",
},
note: {
label: "Note",
description: "Quality tag: 'measured' = exact from status bar, 'userbased' = human-provided, 'workplan' = prorated, 'heuristic' = server fallback.",
doc: "/docs/reference#token-note-taxonomy",
},
created_at: {
label: "Created At",
description: "Timestamp when this token event was recorded (UTC).",
},
// ── Workstream ──────────────────────────────────────────────────────────────
slug: {
label: "Slug",
description: "URL-safe short identifier for this entity.",
},
title: {
label: "Title",
description: "Human-readable name for this workstream or task.",
},
status: {
label: "Status",
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
doc: "/docs/workstream-lifecycle",
},
topic_id: {
label: "Topic ID",
description: "The topic this workstream is grouped under.",
doc: "/docs/reference#topics",
},
repo_goal_id: {
label: "Repo Goal ID",
description: "Optional link to a repo-level strategic goal this workstream advances.",
doc: "/docs/goals",
},
// ── Task ───────────────────────────────────────────────────────────────────
assignee: {
label: "Assignee",
description: "Who is responsible for completing this task (agent name or human).",
},
priority: {
label: "Priority",
description: "Relative urgency: high, medium, or low.",
},
due_date: {
label: "Due Date",
description: "Target completion date (ISO 8601).",
},
needs_human: {
label: "Needs Human",
description: "True if the task is blocked waiting for human input or approval.",
doc: "/interventions",
},
intervention_note: {
label: "Intervention Note",
description: "Why human intervention is required for this task.",
},
// ── Repo ───────────────────────────────────────────────────────────────────
repo_slug: {
label: "Repo Slug",
description: "Short identifier for the repository (matches the git remote slug).",
doc: "/docs/repos",
},
event_count: {
label: "Event Count",
description: "Total number of token events attributed to this entity.",
},
by_model: {
label: "By Model",
description: "Token totals broken down by Claude model.",
},
by_note: {
label: "By Note",
description: "Token totals broken down by quality tier (measured / workplan / heuristic).",
doc: "/docs/reference#token-note-taxonomy",
},
};
/**
* Render a single key-value row for an entity detail table.
* @param {string} key — field name
* @param {*} value — field value (stringified automatically)
* @returns {HTMLTableRowElement}
*/
export function fieldRow(key, value) {
const tr = document.createElement("tr");
// Key cell
const tdKey = document.createElement("td");
tdKey.style.cssText = "padding:0.3rem 0.8rem 0.3rem 0; white-space:nowrap; vertical-align:top; color:var(--theme-foreground-muted,#666); font-size:0.82rem;";
const help = FIELD_HELP[key];
if (help) {
const tip = document.createElement("help-tip");
tip.setAttribute("label", help.label);
tip.setAttribute("description", help.description);
if (help.doc) tip.setAttribute("doc", help.doc);
tip.textContent = help.label;
tdKey.appendChild(tip);
} else {
tdKey.textContent = key;
}
tr.appendChild(tdKey);
// Value cell
const tdVal = document.createElement("td");
tdVal.style.cssText = "padding:0.3rem 0; font-size:0.82rem; word-break:break-all; vertical-align:top;";
let display;
if (value === null || value === undefined) {
display = document.createElement("span");
display.style.color = "var(--theme-foreground-faint,#aaa)";
display.textContent = "—";
} else if (key in FIELD_LINKS) {
display = _linkCell(key, value);
} else if (typeof value === "object") {
display = document.createElement("pre");
display.style.cssText = "margin:0; font-size:0.75rem; white-space:pre-wrap;";
display.textContent = JSON.stringify(value, null, 2);
} else {
display = document.createElement("span");
display.textContent = String(value);
}
tdVal.appendChild(display);
tr.appendChild(tdVal);
return tr;
}

View File

@@ -0,0 +1,101 @@
// refCell(index, recordType, id) → HTMLElement
//
// Renders a 1-based row number in a table cell.
// Single click — copies deep-link to clipboard and flashes "Copied!".
// Double click — opens deep-link in a new tab.
//
// Deep-link format: <origin>/data/<recordType>/<id>
//
// Usage:
// import {refCell} from "./components/ref-cell.js";
// // in an Inputs.table format callback:
// format: { id: (_, i) => refCell(i + 1, "token-events", row.id) }
const _STYLE_ID = "refcell-global-style";
if (!document.getElementById(_STYLE_ID)) {
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
.ref-cell {
display: inline-block;
font-family: var(--monospace, monospace);
font-size: 0.78rem;
color: var(--theme-foreground-focus, #3b82f6);
cursor: pointer;
user-select: none;
padding: 0 2px;
border-radius: 3px;
transition: background 0.1s;
}
.ref-cell:hover {
background: var(--theme-foreground-faint, #e8f0fe);
}
.ref-cell-toast {
position: fixed;
z-index: 10000;
background: var(--theme-background, #fff);
border: 1px solid var(--theme-foreground-faint, #ddd);
border-radius: 6px;
padding: 0.3rem 0.65rem;
font-size: 0.75rem;
color: var(--theme-foreground, #333);
box-shadow: 0 4px 14px rgba(0,0,0,0.12);
opacity: 0;
transition: opacity 0.1s ease;
pointer-events: none;
}
.ref-cell-toast.ref-cell-toast-visible { opacity: 1; }
`;
document.head.appendChild(s);
}
function _showToast(anchorEl, text) {
const toast = document.createElement("div");
toast.className = "ref-cell-toast";
toast.textContent = text;
document.body.appendChild(toast);
const rect = anchorEl.getBoundingClientRect();
const gap = 6;
toast.style.left = `${rect.left}px`;
toast.style.top = `${rect.top - toast.offsetHeight - gap}px`;
requestAnimationFrame(() => toast.classList.add("ref-cell-toast-visible"));
setTimeout(() => {
toast.classList.remove("ref-cell-toast-visible");
toast.addEventListener("transitionend", () => toast.remove(), {once: true});
}, 1200);
}
export function refCell(index, recordType, id) {
const deepLink = `${location.origin}/${recordType}/${id}`;
const el = document.createElement("span");
el.className = "ref-cell";
el.title = `Click to copy link · Double-click to open\n${deepLink}`;
el.textContent = String(index);
let clickTimer = null;
el.addEventListener("click", (e) => {
e.stopPropagation();
// Use a short delay so a double-click cancels the single-click handler.
clickTimer = setTimeout(async () => {
try {
await navigator.clipboard.writeText(deepLink);
_showToast(el, "Copied!");
} catch {
// Fallback for environments where clipboard API is blocked.
_showToast(el, deepLink);
}
}, 180);
});
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
clearTimeout(clickTimer);
window.open(deepLink, "_blank", "noopener,noreferrer");
});
return el;
}

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Observable data loader: fetches a single repo by slug."""
import json
import os
import sys
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
slug = sys.argv[1] if len(sys.argv) > 1 else ""
if not slug:
print(json.dumps({"error": "No repo slug provided"}))
sys.exit(1)
try:
with urllib.request.urlopen(f"{API_BASE}/repos/{slug}/", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.HTTPError as e:
if e.code == 404:
print(json.dumps({"error": f"Repo {slug!r} not found"}))
sys.exit(1)
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
sys.exit(1)
except urllib.error.URLError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Observable data loader: fetches a single token event by ID."""
import json
import os
import sys
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
event_id = sys.argv[1] if len(sys.argv) > 1 else ""
if not event_id:
print(json.dumps({"error": "No event ID provided"}))
sys.exit(1)
try:
with urllib.request.urlopen(f"{API_BASE}/token-events/{event_id}", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.HTTPError as e:
if e.code == 404:
print(json.dumps({"error": f"Token event {event_id!r} not found"}))
sys.exit(1)
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
sys.exit(1)
except urllib.error.URLError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Observable data loader: fetches a single workstream by ID."""
import json
import os
import sys
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
ws_id = sys.argv[1] if len(sys.argv) > 1 else ""
if not ws_id:
print(json.dumps({"error": "No workstream ID provided"}))
sys.exit(1)
try:
with urllib.request.urlopen(f"{API_BASE}/workstreams/{ws_id}", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.HTTPError as e:
if e.code == 404:
print(json.dumps({"error": f"Workstream {ws_id!r} not found"}))
sys.exit(1)
print(json.dumps({"error": f"HTTP {e.code}: {e.reason}"}))
sys.exit(1)
except urllib.error.URLError as e:
print(json.dumps({"error": str(e)}))
sys.exit(1)

View File

@@ -9,21 +9,26 @@ import {API, POLL} from "./components/config.js";
```js
const progState = (async function*() {
while (true) {
let data = [], ok = false;
let data = [], tokenEvents = [], ok = false;
try {
const r = await fetch(`${API}/progress/?limit=500`);
ok = r.ok;
data = ok ? await r.json() : [];
const [r1, r2] = await Promise.all([
fetch(`${API}/progress/?limit=500`),
fetch(`${API}/token-events/?limit=1000`),
]);
ok = r1.ok;
data = ok ? await r1.json() : [];
tokenEvents = r2.ok ? await r2.json() : [];
} catch {}
yield {data, ok, ts: new Date()};
yield {data, tokenEvents, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = progState.data ?? [];
const _ok = progState.ok ?? false;
const data = progState.data ?? [];
const tokenEvents = progState.tokenEvents ?? [];
const _ok = progState.ok ?? false;
const _ts = progState.ts;
```
@@ -52,31 +57,55 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/progress-log
import * as Plot from "npm:@observablehq/plot";
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const byDay = Object.entries(
data
.filter(e => new Date(e.created_at) >= cutoff)
.reduce((acc, e) => {
const day = e.created_at.slice(0, 10);
acc[day] = (acc[day] ?? 0) + 1;
return acc;
}, {})
.reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + 1; return acc; }, {})
).sort().map(([day, count]) => ({day, count}));
const tokensByDay = Object.entries(
tokenEvents
.filter(e => new Date(e.created_at) >= cutoff)
.reduce((acc, e) => { const d = e.created_at.slice(0, 10); acc[d] = (acc[d] ?? 0) + (e.tokens_in || 0) + (e.tokens_out || 0); return acc; }, {})
).sort().map(([day, tokens]) => ({day, tokens}));
// Scale tokens onto the events axis for dual-axis display
const maxEvents = Math.max(1, ...byDay.map(d => d.count));
const maxTokens = Math.max(1, ...tokensByDay.map(d => d.tokens));
const ratio = maxTokens / maxEvents;
const fmtTokens = v => { const t = v * ratio; return t >= 1e6 ? (t/1e6).toFixed(1)+"M" : t >= 1e3 ? Math.round(t/1e3)+"k" : String(Math.round(t)); };
display(byDay.length === 0
? html`<p style="color:gray">No events in the last 30 days.</p>`
: Plot.plot({
title: "Progress events per day (30-day window)",
title: "Progress events & tokens per day (30-day window)",
x: {label: "Date", tickRotate: -30},
y: {label: "Events", grid: true},
y: {label: "Events", grid: true},
marginBottom: 60,
marginRight: tokensByDay.length > 0 ? 70 : 20,
width: 750,
marks: [
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3}),
Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue"}),
Plot.areaY(byDay, {x: "day", y: "count", fill: "steelblue", fillOpacity: 0.25}),
Plot.lineY(byDay, {x: "day", y: "count", stroke: "steelblue", strokeWidth: 1.5}),
Plot.tip(byDay, Plot.pointerX({x: "day", y: "count",
title: d => `${d.day}\n${d.count} event${d.count === 1 ? "" : "s"}`})),
...(tokensByDay.length > 0 ? [
Plot.areaY(tokensByDay, {x: "day", y: d => d.tokens / ratio, fill: "#f28e2b", fillOpacity: 0.2}),
Plot.lineY(tokensByDay, {x: "day", y: d => d.tokens / ratio, stroke: "#f28e2b", strokeWidth: 1.5}),
Plot.tip(tokensByDay, Plot.pointerX({x: "day", y: d => d.tokens / ratio,
title: d => `${d.day}\n${d.tokens.toLocaleString()} tokens`})),
Plot.axisY({anchor: "right", label: "Tokens →", tickFormat: fmtTokens}),
] : []),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 750,
})
);
if (byDay.length > 0) display(html`<div style="font-size:0.78rem;display:flex;gap:1.2rem;margin-top:0.4rem;color:var(--theme-foreground-muted)">
<span><span style="color:steelblue">▬</span> Events (left axis)</span>
${tokensByDay.length > 0 ? html`<span><span style="color:#f28e2b">▬</span> Tokens (right axis)</span>` : html`<span style="font-style:italic">No token data yet</span>`}
</div>`);
```
## Event Log

View File

@@ -54,6 +54,28 @@ convention used in the Custodian State Hub.
---
## Entity Detail Pages & REF Column
Every entity table in the dashboard includes a **REF** column (leftmost) that shows
the 1-based row number for the current view. Clicking the REF number copies a
deep-link to the clipboard in the format `/data/<recordtype>/<id>`. Double-clicking
opens that link in a new tab.
The deep-link target is an entity detail page that renders all fields of the record
in a key-value layout. Each field key is decorated with a `<help-tip>` that shows a
one-sentence description of the field and a "Learn more" link to the relevant
documentation section.
Currently implemented record types:
| Record type | URL pattern | Source endpoint |
|-------------|-------------|-----------------|
| `token-events` | `/token-events/<id>` | `GET /token-events/{id}` |
Further record types (repos, workstreams, tasks) will be added in subsequent workplans.
---
## Meta
| Topic | What it covers |

View File

@@ -0,0 +1,42 @@
---
title: Repo
---
```js
import {API} from "../components/config.js";
import {fieldRow} from "../components/field-help.js";
```
```js
const repoSlug = observable.params.slug;
const raw = await fetch(`${API}/repos/${repoSlug}/`)
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
.catch(e => ({error: String(e)}));
```
```js
if (raw.error) {
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} else {
const name = raw.name || raw.slug || repoSlug;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Repo · <em>${name}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/repos">← Repos</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
const FIELD_ORDER = [
"id","slug","name","domain_slug","status","description",
"local_path","remote_url","git_fingerprint",
"sbom_source","last_sbom_at","last_state_synced_at",
"created_at","updated_at",
];
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
for (const k of Object.keys(raw)) {
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
}
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
<colgroup><col style="width:160px"><col></colgroup>
<tbody>${rows}</tbody>
</table>`);
}
```

View File

@@ -0,0 +1,42 @@
---
title: Task
---
```js
import {API} from "../components/config.js";
import {fieldRow} from "../components/field-help.js";
```
```js
const taskId = observable.params.id;
const raw = await fetch(`${API}/tasks/${taskId}`)
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
.catch(e => ({error: String(e)}));
```
```js
if (raw.error) {
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} else {
const name = raw.title || taskId;
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Task · <em>${shortName}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/tasks">← Tasks</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
const FIELD_ORDER = [
"id","title","status","priority","assignee",
"workstream_id","due_date","needs_human","intervention_note",
"created_at","updated_at",
];
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
for (const k of Object.keys(raw)) {
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
}
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
<colgroup><col style="width:160px"><col></colgroup>
<tbody>${rows}</tbody>
</table>`);
}
```

View File

@@ -4,68 +4,107 @@ title: Token Cost
```js
import {API} from "./components/config.js";
import {refCell} from "./components/ref-cell.js";
const POLL = 60_000;
```
```js
// Live poll for token data
// Fetch token events, by-repo summary, workstreams, and tasks in parallel
const tokenState = (async function*() {
while (true) {
let data = {by_repo: [], by_workstream: [], top_tasks: [], by_model: [], total_events: 0}, ok = false;
let byRepo = [], events = [], wsMap = {}, taskMap = {}, ok = false;
try {
const r = await fetch(`${API}/token-events/?limit=1000`);
ok = r.ok;
const [r1, r2, r3, r4] = await Promise.all([
fetch(`${API}/token-events/by-repo/`),
fetch(`${API}/token-events/?limit=1000`),
fetch(`${API}/workstreams/`),
fetch(`${API}/tasks/`),
]);
ok = r1.ok && r2.ok;
if (ok) {
const events = await r.json();
data = buildSummary(events);
byRepo = await r1.json();
events = await r2.json();
}
if (r3.ok) {
const wsList = await r3.json();
for (const w of wsList) wsMap[w.id] = w;
}
if (r4.ok) {
const taskList = await r4.json();
for (const t of taskList) taskMap[t.id] = t;
}
} catch {}
yield {data, ok, ts: new Date()};
yield {byRepo, events, wsMap, taskMap, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
// Resolve an event's repo_id via the 3-level chain: direct → workstream → task→workstream
function resolveRepoId(e, wsMap, taskMap) {
if (e.repo_id) return e.repo_id;
const wsId = e.workstream_id ?? taskMap[e.task_id]?.workstream_id;
return wsId ? (wsMap[wsId]?.repo_id ?? null) : null;
}
function buildSummary(events) {
const byRepo = {}, byWs = {}, byModel = {}, byTask = {};
const byWs = {}, byModel = {}, byTask = {};
for (const e of events) {
const tot = (e.tokens_in || 0) + (e.tokens_out || 0);
if (e.repo_id) {
byRepo[e.repo_id] = byRepo[e.repo_id] || {scope_id: e.repo_id, tokens_in: 0, tokens_out: 0, event_count: 0};
byRepo[e.repo_id].tokens_in += e.tokens_in || 0;
byRepo[e.repo_id].tokens_out += e.tokens_out || 0;
byRepo[e.repo_id].event_count++;
}
if (e.workstream_id) {
byWs[e.workstream_id] = byWs[e.workstream_id] || {scope_id: e.workstream_id, tokens_in: 0, tokens_out: 0, event_count: 0};
byWs[e.workstream_id].tokens_in += e.tokens_in || 0;
byWs[e.workstream_id].tokens_in += e.tokens_in || 0;
byWs[e.workstream_id].tokens_out += e.tokens_out || 0;
byWs[e.workstream_id].event_count++;
}
const model = e.model || "unknown";
byModel[model] = (byModel[model] || 0) + tot;
if (e.task_id) {
byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0};
byTask[e.task_id].tokens_in += e.tokens_in || 0;
byTask[e.task_id] = byTask[e.task_id] || {task_id: e.task_id, tokens_in: 0, tokens_out: 0, event_count: 0};
byTask[e.task_id].tokens_in += e.tokens_in || 0;
byTask[e.task_id].tokens_out += e.tokens_out || 0;
byTask[e.task_id].event_count++;
}
}
const sortDesc = obj => Object.entries(obj)
.map(([k,v]) => typeof v === "number" ? {id: k, tokens_total: v} : {...v, tokens_total: (v.tokens_in||0)+(v.tokens_out||0)})
.sort((a,b) => b.tokens_total - a.tokens_total);
const toRows = obj => Object.values(obj)
.map(v => ({...v, tokens_total: (v.tokens_in || 0) + (v.tokens_out || 0)}))
.sort((a, b) => b.tokens_total - a.tokens_total);
return {
by_repo: sortDesc(byRepo),
by_workstream: sortDesc(byWs),
by_model: Object.entries(byModel).map(([model,tokens_total]) => ({model,tokens_total})).sort((a,b)=>b.tokens_total-a.tokens_total),
top_tasks: sortDesc(byTask).slice(0,10),
by_workstream: toRows(byWs),
by_model: Object.entries(byModel)
.map(([model, tokens_total]) => ({model, tokens_total}))
.sort((a, b) => b.tokens_total - a.tokens_total),
top_tasks: toRows(byTask),
total_events: events.length,
};
}
function nameCell(name, fullName) {
const s = String(name ?? fullName ?? "—");
const full = String(fullName ?? name ?? "—");
const el = document.createElement("span");
el.title = full;
el.textContent = s.length > 80 ? s.slice(0, 80) + "…" : s;
return el;
}
function sortRows(rows, sortField) {
if (sortField === "Tokens Total") return rows; // already sorted by buildSummary / by-repo API
const s = [...rows];
if (sortField === "Tokens In") s.sort((a, b) => (b.tokens_in || 0) - (a.tokens_in || 0));
else if (sortField === "Tokens Out") s.sort((a, b) => (b.tokens_out || 0) - (a.tokens_out || 0));
else if (sortField === "Event Count") s.sort((a, b) => (b.event_count || 0) - (a.event_count || 0));
else if (sortField === "Most Recent") s.sort((a, b) => (b._lastAt || 0) - (a._lastAt || 0));
return s;
}
```
```js
const td = tokenState.data ?? {by_repo:[], by_workstream:[], top_tasks:[], by_model:[], total_events:0};
const byRepo = tokenState.byRepo ?? [];
const events = tokenState.events ?? [];
const wsMap = tokenState.wsMap ?? {};
const taskMap = tokenState.taskMap ?? {};
const _ok = tokenState.ok ?? false;
const _ts = tokenState.ts;
```
@@ -73,69 +112,158 @@ const _ts = tokenState.ts;
# Token Cost
```js
const _liveEl = html`<div style="font-size:0.8rem;color:${_ok?'var(--theme-foreground-focus)':'red'}">
${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${td.total_events} events` : "API offline"}
</div>`;
display(_liveEl);
display(html`<div style="font-size:0.8rem;color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">
${_ok ? `Live · ${_ts?.toLocaleTimeString()} · ${events.length} events` : "API offline"}
</div>`);
```
```js
const repoSel = Inputs.select(
["All repos", ...byRepo.map(r => r.repo_slug)],
{label: "Filter by repo"}
);
const sortSel = Inputs.select(
["Tokens Total", "Tokens In", "Tokens Out", "Event Count", "Most Recent"],
{label: "Sort by"}
);
const maxSel = Inputs.select(
[10, 20, 50, 100, 500],
{value: 20, label: "Show"}
);
display(html`<div style="display:flex;gap:1.5rem;align-items:flex-end;flex-wrap:wrap;margin:0.5rem 0 1.5rem">${repoSel}${sortSel}${maxSel}</div>`);
const repoFilter = view(repoSel);
const sortOrder = view(sortSel);
const maxResults = view(maxSel);
```
```js
// Build filtered and last-event-annotated row sets
const selectedRepoId = repoFilter === "All repos"
? null
: (byRepo.find(r => r.repo_slug === repoFilter)?.repo_id ?? null);
const filteredEvents = selectedRepoId
? events.filter(e => resolveRepoId(e, wsMap, taskMap) === selectedRepoId)
: events;
const lastAtByRepo = {}, lastAtByWs = {}, lastAtByTask = {};
for (const e of filteredEvents) {
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
const rid = resolveRepoId(e, wsMap, taskMap);
if (rid) lastAtByRepo[rid] = Math.max(lastAtByRepo[rid] || 0, t);
if (e.workstream_id) lastAtByWs[e.workstream_id] = Math.max(lastAtByWs[e.workstream_id] || 0, t);
if (e.task_id) lastAtByTask[e.task_id] = Math.max(lastAtByTask[e.task_id] || 0, t);
}
const filteredByRepo = (selectedRepoId
? byRepo.filter(r => r.repo_id === selectedRepoId)
: byRepo
).map(r => ({...r, _lastAt: lastAtByRepo[r.repo_id] || 0}));
const summary = buildSummary(filteredEvents);
const wsRowsFull = summary.by_workstream.map(r => ({...r, _lastAt: lastAtByWs[r.scope_id] || 0}));
const taskRowsFull = summary.top_tasks.map(r => ({...r, _lastAt: lastAtByTask[r.task_id] || 0}));
```
## By Repo
```js
if (td.by_repo.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No token events recorded yet.</p>`);
} else {
display(Plot.plot({
title: "Token consumption by repo",
marginLeft: 160,
width: Math.min(900, width),
x: {label: "Tokens", tickFormat: "~s"},
y: {label: null},
color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
marks: [
Plot.barX(
td.by_repo.flatMap(r => [
{repo: r.scope_id.slice(0,8), type: "tokens_in", value: r.tokens_in},
{repo: r.scope_id.slice(0,8), type: "tokens_out", value: r.tokens_out},
]),
{x: "value", y: "repo", fill: "type", tip: true}
),
],
}));
{
const sorted = sortRows(filteredByRepo, sortOrder);
const total = sorted.length;
const rows = sorted.slice(0, maxResults);
if (rows.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No token events with repo association yet.</p>`);
} else {
display(Plot.plot({
title: "Token consumption by repo",
marginLeft: 160,
width: Math.min(900, width),
x: {label: "Tokens", tickFormat: "~s"},
y: {label: null},
color: {legend: true, domain: ["tokens_in", "tokens_out"], range: ["#4e79a7","#f28e2b"]},
marks: [
Plot.barX(
rows.flatMap(r => [
{repo: r.repo_slug, type: "tokens_in", value: r.tokens_in},
{repo: r.repo_slug, type: "tokens_out", value: r.tokens_out},
]),
{x: "value", y: "repo", fill: "type", tip: true}
),
],
}));
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
columns: ["_ref", "repo_slug", "tokens_in", "tokens_out", "tokens_total", "event_count"],
header: {
_ref: "REF",
repo_slug: "Repo",
tokens_in: "Tokens In",
tokens_out: "Tokens Out",
tokens_total: "Total",
event_count: "Events",
},
format: {
_ref: (_, i) => refCell(i + 1, "repos", rows[i].repo_slug),
repo_slug: d => nameCell(d, d),
tokens_in: d => d.toLocaleString(),
tokens_out: d => d.toLocaleString(),
tokens_total: d => d.toLocaleString(),
},
width: {_ref: 50, repo_slug: 160, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
}));
if (total > maxResults)
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} repos</p>`);
}
}
```
## By Workplan
```js
const wsRows = td.by_workstream.slice(0, 20);
if (wsRows.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
} else {
display(Inputs.table(wsRows, {
columns: ["scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
header: {
scope_id: "Workstream ID",
tokens_in: "Tokens In",
tokens_out: "Tokens Out",
tokens_total: "Total",
event_count: "Events",
},
format: {
scope_id: d => d.slice(0,8) + "…",
tokens_in: d => d.toLocaleString(),
tokens_out: d => d.toLocaleString(),
tokens_total: d => d.toLocaleString(),
},
width: {scope_id: 120, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
}));
{
const sorted = sortRows(wsRowsFull, sortOrder);
const total = sorted.length;
const rows = sorted.slice(0, maxResults);
if (rows.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No workstream data yet.</p>`);
} else {
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
columns: ["_ref", "scope_id", "tokens_in", "tokens_out", "tokens_total", "event_count"],
header: {
_ref: "REF",
scope_id: "Workstream",
tokens_in: "Tokens In",
tokens_out: "Tokens Out",
tokens_total: "Total",
event_count: "Events",
},
format: {
_ref: (_, i) => refCell(i + 1, "workstreams", rows[i].scope_id),
scope_id: d => {
const ws = wsMap[d];
return nameCell(ws?.title ?? ws?.slug, d);
},
tokens_in: d => d.toLocaleString(),
tokens_out: d => d.toLocaleString(),
tokens_total: d => d.toLocaleString(),
},
width: {_ref: 50, scope_id: 200, tokens_in: 110, tokens_out: 110, tokens_total: 110, event_count: 80},
}));
if (total > maxResults)
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} workstreams</p>`);
}
}
```
## By Model
```js
if (td.by_model.length === 0) {
if (summary.by_model.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No model data yet.</p>`);
} else {
display(Plot.plot({
@@ -144,27 +272,41 @@ if (td.by_model.length === 0) {
width: Math.min(700, width),
x: {label: "Total tokens", tickFormat: "~s"},
marks: [
Plot.barX(td.by_model, {x: "tokens_total", y: "model", fill: "#4e79a7", tip: true}),
Plot.barX(summary.by_model, {x: "tokens_total", y: "model", fill: "#4e79a7", tip: true}),
],
}));
}
```
## Top 10 Tasks by Tokens
## Top Tasks by Tokens
```js
if (td.top_tasks.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
} else {
display(Inputs.table(td.top_tasks, {
columns: ["task_id", "tokens_in", "tokens_out", "tokens_total"],
header: {task_id: "Task ID", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
format: {
task_id: d => d.slice(0,8) + "…",
tokens_in: d => d.toLocaleString(),
tokens_out: d => d.toLocaleString(),
tokens_total: d => d.toLocaleString(),
},
}));
{
const sorted = sortRows(taskRowsFull, sortOrder);
const total = sorted.length;
const rows = sorted.slice(0, maxResults);
if (rows.length === 0) {
display(html`<p style="color:var(--theme-foreground-muted)">No task-level data yet.</p>`);
} else {
display(Inputs.table(rows.map((r, i) => ({...r, _ref: i})), {
columns: ["_ref", "task_id", "tokens_in", "tokens_out", "tokens_total"],
header: {_ref: "REF", task_id: "Task", tokens_in: "In", tokens_out: "Out", tokens_total: "Total"},
format: {
_ref: (_, i) => refCell(i + 1, "tasks", rows[i].task_id),
task_id: d => {
const task = taskMap[d];
return nameCell(task?.title, d);
},
tokens_in: d => d.toLocaleString(),
tokens_out: d => d.toLocaleString(),
tokens_total: d => d.toLocaleString(),
},
width: {_ref: 50, task_id: 240},
}));
if (total > maxResults)
display(html`<p style="font-size:0.8rem;color:var(--theme-foreground-muted);margin-top:0.25rem">Showing ${maxResults} of ${total} tasks</p>`);
}
}
```

View File

@@ -0,0 +1,42 @@
---
title: Token Event
---
```js
import {API} from "../components/config.js";
import {fieldRow} from "../components/field-help.js";
```
```js
const eventId = observable.params.id;
const raw = await fetch(`${API}/token-events/${eventId}`)
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
.catch(e => ({error: String(e)}));
```
```js
if (raw.error) {
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} else {
const shortId = raw.id ? raw.id.slice(0, 8) + "…" : eventId;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Token Event · <code>${shortId}</code></h1>`);
display(html`<p style="margin-top:0"><a href="/token-cost">← Token Cost</a></p>`);
const FIELD_ORDER = [
"id","tokens_in","tokens_out","tokens_total",
"note","model","agent","session_id",
"task_id","workstream_id","repo_id",
"ref_type","ref_id","created_at",
];
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
for (const k of Object.keys(raw)) {
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
}
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
<colgroup><col style="width:160px"><col></colgroup>
<tbody>${rows}</tbody>
</table>`);
}
```

View File

@@ -0,0 +1,41 @@
---
title: Workstream
---
```js
import {API} from "../components/config.js";
import {fieldRow} from "../components/field-help.js";
```
```js
const wsId = observable.params.id;
const raw = await fetch(`${API}/workstreams/${wsId}`)
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
.catch(e => ({error: String(e)}));
```
```js
if (raw.error) {
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
} else {
const name = raw.title || raw.slug || wsId;
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
display(html`<p style="margin-top:0"><a href="/workstreams">← Workstreams</a> &nbsp;|&nbsp; <a href="/token-cost">← Token Cost</a></p>`);
const FIELD_ORDER = [
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
"created_at","updated_at",
];
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
for (const k of Object.keys(raw)) {
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
}
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
<colgroup><col style="width:160px"><col></colgroup>
<tbody>${rows}</tbody>
</table>`);
}
```

View File

@@ -83,6 +83,16 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via
|------|----------|-------|
| `record_token_event(tokens_in, tokens_out, ...)` | `task_id`?, `workstream_id`?, `repo_id`?, `model`?, `agent`?, `ref_type`?, `ref_id`?, `note`?, `session_id`? | POSTs to `/token-events/`. `workstream_id` auto-filled from task. Returns event id + running total. |
| `get_token_summary(scope, id)` | `scope`: task\|workstream\|repo\|commit\|release\|session; `id`: UUID or ref string | Returns formatted table of tokens_in/out/total, event_count, by_model, by_agent. |
| `record_interactive_task(title, repo_slug, ...)` | `tokens_in`?, `tokens_out`?, `note`?, `model`?, `agent`?, `description`?, `session_id`? | Find-or-create `interactive-<repo>` workstream, create task, mark done, record token event. |
**Token note taxonomy:**
| note | meaning |
|------|---------|
| `"measured"` | Exact counts read from Claude Code status bar — default when `tokens_in`/`tokens_out` provided |
| `"userbased"` | Counts provided by a human (pass `note="userbased"` explicitly) |
| `"workplan"` | Prorated from workplan total across task count |
| `"heuristic"` | Server fallback — 1 000 in / 500 out, no agent input |
---

View File

@@ -428,29 +428,58 @@ def update_task_status(
blocking_reason: Optional[str] = None,
tokens_in: Optional[int] = None,
tokens_out: Optional[int] = None,
workplan_tokens_in: Optional[int] = None,
workplan_tokens_out: Optional[int] = None,
note: Optional[str] = None,
model: Optional[str] = None,
agent: Optional[str] = None,
session_id: Optional[str] = None,
) -> str:
"""Update a task's status. blocking_reason is required when status='blocked'.
Optionally record token consumption in one call by passing tokens_in/tokens_out.
When provided, a token_event is created automatically with workstream_id and
repo_id auto-populated from the task.
When status='done', always records a token event using the best available data:
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
note defaults to "measured"; pass note="userbased" if the
numbers were provided by a human rather than read from the bar
Tier 2: pass workplan_tokens_in + workplan_tokens_out — total workplan
effort prorated across task count (note="workplan")
Tier 3 (fallback): no token args — heuristic 1000 in / 500 out (note="heuristic")
Best practice: read tokens from the Claude Code status bar and pass exact counts.
Args:
task_id: UUID of the task
status: todo | in_progress | blocked | done | cancelled
blocking_reason: required when status=blocked
tokens_in: optional input token count (triggers token_event creation)
tokens_out: optional output token count (required if tokens_in provided)
model: optional model identifier, e.g. 'claude-sonnet-4-6'
agent: optional agent name, e.g. 'custodian', 'ralph'
session_id: optional agent session identifier
tokens_in: exact input token count for this task (Tier 1)
tokens_out: exact output token count for this task (Tier 1)
workplan_tokens_in: total input tokens for the whole workplan (Tier 2)
workplan_tokens_out: total output tokens for the whole workplan (Tier 2)
note: override the auto note — use "userbased" when counts came from a human;
omit to get the default ("measured" for Tier 1, "workplan"/"heuristic" otherwise)
model: model identifier, e.g. 'claude-sonnet-4-6'
agent: agent name, e.g. 'custodian', 'ralph'
session_id: agent session identifier
"""
body: dict[str, Any] = {"status": status}
body: dict[str, Any] = {
"status": status,
"model": model,
"agent": agent,
"session_id": session_id,
}
if blocking_reason:
body["blocking_reason"] = blocking_reason
if tokens_in is not None:
body["tokens_in"] = tokens_in
if tokens_out is not None:
body["tokens_out"] = tokens_out
if workplan_tokens_in is not None:
body["workplan_tokens_in"] = workplan_tokens_in
if workplan_tokens_out is not None:
body["workplan_tokens_out"] = workplan_tokens_out
if note is not None:
body["token_note"] = note
task = _patch(f"/tasks/{task_id}", body)
_post("/progress", {
"task_id": task_id,
@@ -461,19 +490,6 @@ def update_task_status(
"detail": {"blocking_reason": blocking_reason},
})
if tokens_in is not None and tokens_out is not None:
_post("/token-events", {
"task_id": task_id,
"workstream_id": task.get("workstream_id"),
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"model": model,
"agent": agent,
"session_id": session_id,
"ref_type": "task",
"ref_id": task_id,
})
return json.dumps(task, indent=2)
@@ -2213,6 +2229,122 @@ def get_doi_summary() -> str:
return json.dumps(_get("/repos/doi/summary"), indent=2)
# ---------------------------------------------------------------------------
# Interactive / ad-hoc task recording
# ---------------------------------------------------------------------------
@mcp.tool()
def record_interactive_task(
title: str,
repo_slug: str,
tokens_in: Optional[int] = None,
tokens_out: Optional[int] = None,
note: Optional[str] = None,
model: Optional[str] = None,
agent: Optional[str] = None,
description: Optional[str] = None,
session_id: Optional[str] = None,
) -> str:
"""Record ad-hoc interactive work as a task with token consumption.
Finds or creates a persistent 'interactive-<repo>' workstream for the repo,
creates the task, marks it done immediately, and records a token event.
Token note convention:
"measured" — exact counts read from the Claude Code status bar (default when
tokens_in/tokens_out provided and note omitted)
"userbased" — counts provided by a human (pass note="userbased" explicitly)
"heuristic" — server fallback when no counts given (automatic)
Use this for work done outside a formal workplan: quick fixes, config changes,
code reviews, one-off investigations, or any session work worth tracking.
Args:
title: Short description of the work done
repo_slug: Registered repo slug, e.g. 'the-custodian', 'inter-hub'
tokens_in: Input token count (Tier 1 — read from Claude Code status bar)
tokens_out: Output token count (Tier 1)
note: Override token note — use "userbased" when counts came from a human
model: Model identifier, e.g. 'claude-sonnet-4-6'
agent: Agent name, e.g. 'custodian', 'ralph'
description: Optional longer description of what was done
session_id: Agent session identifier
"""
# Resolve repo
repos = _get("/repos/")
if isinstance(repos, dict) and "error" in repos:
return json.dumps(repos)
repo = next((r for r in (repos or []) if r.get("slug") == repo_slug), None)
if not repo:
return json.dumps({"error": f"Repo not found: {repo_slug!r}. Register it first with register_repo()."})
repo_id = repo["id"]
domain_slug = repo.get("domain_slug") or repo.get("domain")
ws_slug = f"interactive-{repo_slug}"
# Find or create the interactive workstream
existing = _get("/workstreams/", {"slug": ws_slug})
ws = existing[0] if isinstance(existing, list) and existing else None
if not ws:
# Find a topic for this domain to satisfy the FK
topics = _get("/topics/")
topic = next(
(t for t in (topics if isinstance(topics, list) else [])
if t.get("domain_slug") == domain_slug or t.get("domain") == domain_slug),
None,
)
if not topic:
return json.dumps({"error": f"No topic found for domain {domain_slug!r} — cannot create workstream."})
ws = _post("/workstreams", {
"topic_id": topic["id"],
"slug": ws_slug,
"title": f"Interactive — {repo_slug}",
"description": "Ad-hoc tasks created outside a formal workplan.",
"owner": "custodian",
"repo_id": repo_id,
})
if "error" in ws:
return json.dumps(ws)
# Create task
task = _post("/tasks", {
"workstream_id": ws["id"],
"title": title,
"description": description,
"priority": "medium",
})
if "error" in task:
return json.dumps(task)
# Mark done — triggers three-tier token recording in the router
body: dict[str, Any] = {
"status": "done",
"model": model,
"agent": agent,
"session_id": session_id,
}
if tokens_in is not None:
body["tokens_in"] = tokens_in
if tokens_out is not None:
body["tokens_out"] = tokens_out
if note is not None:
body["token_note"] = note
_patch(f"/tasks/{task['id']}", body)
effective_note = note or ("measured" if tokens_in is not None else "heuristic")
return json.dumps({
"task_id": task["id"],
"workstream_id": ws["id"],
"workstream_slug": ws_slug,
"title": title,
"token_note": effective_note,
}, indent=2)
# ---------------------------------------------------------------------------
# Token events
# ---------------------------------------------------------------------------

View File

@@ -196,3 +196,22 @@ class TestTokenSummary:
s = r.json()
assert s["tokens_total"] == 0
assert s["event_count"] == 0
@pytest.mark.asyncio
class TestTokenEventGetById:
async def test_get_by_id(self, client):
ev = await _post_event(client, tokens_in=111, tokens_out=222, note="get-by-id-test")
r = await client.get(f"/token-events/{ev['id']}")
assert r.status_code == 200
result = r.json()
assert result["id"] == ev["id"]
assert result["tokens_in"] == 111
assert result["tokens_out"] == 222
assert result["tokens_total"] == 333
assert result["note"] == "get-by-id-test"
async def test_get_by_id_not_found(self, client):
import uuid
r = await client.get(f"/token-events/{uuid.uuid4()}")
assert r.status_code == 404

View File

@@ -1,8 +1,12 @@
"""
Token passthrough test: update_task_status with tokens_in/tokens_out
creates a token event automatically.
Token passthrough test: update_task_status creates a token event on done.
Tests the API-level behaviour (the MCP tool delegates to the same endpoints).
Three-tier logic:
Tier 1 — exact tokens_in/tokens_out provided
Tier 2 — workplan_tokens_in/out provided → prorated by task count (note="workplan")
Tier 3 — no token args, status=done → heuristic 1000/500 (note="heuristic")
Non-done status changes never create a token event.
"""
from __future__ import annotations
@@ -27,22 +31,21 @@ async def _create_workstream(client, topic_id):
return r.json()
async def _create_task(client, workstream_id):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": "my task"})
async def _create_task(client, workstream_id, title="my task"):
r = await client.post("/tasks/", json={"workstream_id": workstream_id, "title": title})
assert r.status_code == 201, r.text
return r.json()
@pytest.mark.asyncio
class TestTokenPassthrough:
async def test_update_status_with_tokens_creates_event(self, client):
"""PATCH /tasks/{id} with tokens_in/tokens_out creates a token_event."""
async def test_tier1_exact_tokens(self, client):
"""Tier 1: exact tokens_in/tokens_out → used as-is, no note."""
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
# Update task status with token data
r = await client.patch(f"/tasks/{task['id']}", json={
"status": "done",
"tokens_in": 1200,
@@ -53,10 +56,7 @@ class TestTokenPassthrough:
assert r.status_code == 200
assert r.json()["status"] == "done"
# Token event should now exist for this task
r2 = await client.get("/token-events/", params={"task_id": task["id"]})
assert r2.status_code == 200
events = r2.json()
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
assert len(events) == 1
ev = events[0]
assert ev["tokens_in"] == 1200
@@ -65,9 +65,69 @@ class TestTokenPassthrough:
assert ev["model"] == "claude-sonnet-4-6"
assert ev["agent"] == "custodian"
assert ev["workstream_id"] == ws["id"]
assert ev["note"] == "measured"
async def test_update_status_without_tokens_creates_no_event(self, client):
"""PATCH /tasks/{id} without token fields creates no token_event."""
async def test_tier1_userbased_note_override(self, client):
"""Tier 1 with note='userbased' records that note instead of 'measured'."""
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={
"status": "done",
"tokens_in": 500,
"tokens_out": 200,
"token_note": "userbased",
})
assert r.status_code == 200
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
assert events[0]["note"] == "userbased"
async def test_tier2_workplan_prorated(self, client):
"""Tier 2: workplan totals prorated across 4 tasks → 250/125 each, note='workplan'."""
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
# Create 4 tasks; mark the first done with workplan totals
task = await _create_task(client, ws["id"], "T1")
for title in ["T2", "T3", "T4"]:
await _create_task(client, ws["id"], title)
r = await client.patch(f"/tasks/{task['id']}", json={
"status": "done",
"workplan_tokens_in": 1000,
"workplan_tokens_out": 500,
})
assert r.status_code == 200
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
assert len(events) == 1
ev = events[0]
assert ev["tokens_in"] == 250 # 1000 // 4
assert ev["tokens_out"] == 125 # 500 // 4
assert ev["note"] == "workplan"
async def test_tier3_heuristic_fallback(self, client):
"""Tier 3: status=done with no token args → heuristic 1000/500, note='heuristic'."""
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "done"})
assert r.status_code == 200
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
assert len(events) == 1
ev = events[0]
assert ev["tokens_in"] == 1000
assert ev["tokens_out"] == 500
assert ev["note"] == "heuristic"
async def test_non_done_status_creates_no_event(self, client):
"""Non-done status updates never create a token event."""
await _create_domain(client)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
@@ -76,6 +136,5 @@ class TestTokenPassthrough:
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"})
assert r.status_code == 200
r2 = await client.get("/token-events/", params={"task_id": task["id"]})
assert r2.status_code == 200
assert r2.json() == []
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()
assert events == []

View File

@@ -0,0 +1,332 @@
---
id: CUST-WP-0030
type: workplan
title: "Dashboard Entity List UX"
domain: custodian
repo: the-custodian
status: done
owner: custodian
topic_slug: custodian
created: "2026-03-29"
updated: "2026-03-29"
state_hub_workstream_id: "9d8e1c33-2067-4593-a5d8-d28dda3b1d21"
---
# Dashboard Entity List UX
## Goal
Make every entity table in the dashboard navigable and self-documenting.
Two new UI primitives:
1. **REF cell** — running row number (1-based); click copies a deep-link
(`<origin>/data/<recordtype>/<id>`) to the clipboard; double-click opens
the link in a new tab.
2. **Name cell** — second column, titled after the record type; shows the
entity name truncated at 80 chars with full-name tooltip on hover.
Support these cells with a landing page (`/data/[type]/[id]`) that renders
a key-value view of every field in the record, each key decorated with a
`<help-tip>` (one-sentence description + link to the relevant help-doc
section).
Pilot all three features on the **Token Cost** page, then extend to other
entity tables in later workplans.
## Background
Current entity tables show only IDs (often truncated UUIDs) with no way to
navigate to a record detail view or discover what a field means. For a
person reviewing agent activity the tables are hard to parse. Providing a
running reference number, an entity name column, and a click-through detail
page substantially lowers the cognitive load.
The `<help-tip>` custom element already exists in
`src/components/help-tip.js` and is used on several pages — the field-help
registry will reuse it as the rendering layer.
## Implementation Plan
The pilot targets the Token Cost page. Observable Framework supports
parameterised pages via `[param].md` file naming. The API already exposes
`GET /token-events/?task_id=…` and `GET /token-events/summary/`; a
per-record GET endpoint is needed before the landing page can work.
### Component layer
- `src/components/ref-cell.js` — exports `refCell(index, type, id)` that
returns an `HTMLElement` with click/dblclick handlers.
- `src/components/field-help.js` — exports `FIELD_HELP` map and
`fieldRow(key, value)` helper that wraps the key in `<help-tip>`.
### API layer
- `GET /token-events/{event_id}` — returns `TokenEventRead` for a single
event; needed by the landing page data loader.
### Dashboard pages
- `src/data/token-events/[id].json.py` — data loader that calls the new
single-event endpoint; returns JSON for the landing page.
- `src/data/token-events/[id].md` — landing page: key-value table, each
row built with `fieldRow()`.
- `src/token-cost.md` — updated: REF column (refCell) and Name column
added to By Repo, By Workplan, and Top Tasks tables.
## Tasks
```task
id: T01
title: "REF cell component"
status: done
priority: high
description: >
Create src/components/ref-cell.js.
Export refCell(index, recordType, id) → HTMLElement.
- Displays 1-based index as monospace text, cursor:pointer.
- Single click: copies `${location.origin}/data/${recordType}/${id}` to
clipboard; briefly flashes "Copied!" as a transient tooltip using the
existing help-tip positioning pattern.
- Double click: window.open(deeplink, '_blank').
- No external dependencies; plain DOM.
state_hub_task_id: "8ee527cf-436b-4bab-bdb8-406314a38d99"
```
```task
id: T02
title: "GET /token-events/{event_id} endpoint"
status: done
priority: high
description: >
Add GET /token-events/{event_id} to api/routers/token_events.py.
Returns TokenEventRead (already defined in schemas/token_event.py).
404 if not found.
No migration needed.
Add a test in tests/test_token_events.py: get by id → 200, unknown id → 404.
state_hub_task_id: "02c27d25-d744-4da0-9bcb-b40ada54d5a5"
```
```task
id: T03
title: "Field-help registry"
status: done
priority: medium
description: >
Create src/components/field-help.js.
Export FIELD_HELP: a plain object keyed by field name.
Each entry: { label, description, doc } — matches help-tip attributes.
Cover at minimum all TokenEventRead fields:
id, tokens_in, tokens_out, tokens_total, task_id, workstream_id,
repo_id, session_id, model, agent, ref_type, ref_id, note, created_at.
Export fieldRow(key, value) → HTMLElement (<tr>) that wraps key in a
<help-tip> (or plain <td> if key is not in FIELD_HELP) and value in a
second <td>.
Import HelpTip from ./help-tip.js to ensure the custom element is
registered.
state_hub_task_id: "7721d884-bd70-459f-b36d-450d69aac549"
```
```task
id: T04
title: "Token-event landing page"
status: done
priority: medium
description: >
Create two files:
1. src/data/token-events/[id].json.py — data loader.
Reads `id` from argv[1]; calls GET /token-events/{id};
exits 1 if 404 so Observable renders an error page.
2. src/data/token-events/[id].md — landing page.
Imports fieldRow from components/field-help.js.
Fetches FileAttachment("token-events/{id}.json").json().
Renders an HTML <table> with one row per field using fieldRow().
Title: "Token Event · {id.slice(0,8)}…".
Back link: "<a href='/token-cost'>← Token Cost</a>".
state_hub_task_id: "dc63746d-74b3-434a-925c-1cead480198f"
```
```task
id: T05
title: "Apply REF and Name columns to Token Cost page"
status: done
priority: high
description: >
Update src/token-cost.md.
Import refCell from ./components/ref-cell.js.
For each of the three entity tables (By Repo, By Workplan, Top Tasks):
- Prepend a "REF" column using refCell(i+1, recordType, row.id).
Record types: "repos" for By Repo (using repo_id), "workstreams" for
By Workplan (using scope_id), "token-events" for Top Tasks (using
task_id — note: links to the task landing page, not a token event page,
until T04 is done; use recordType "tasks").
- Add a Name column as the second data column:
- By Repo: repo_slug (no truncation needed, slugs are short)
- By Workplan: scope_id displayed as first 8 chars + "…" (unchanged)
→ replace with workstream title if available; for now show scope_id
truncated to 36 chars with full UUID tooltip.
- Top Tasks: task_id truncated to 80 chars with full-id tooltip.
Keep all existing columns unchanged.
state_hub_task_id: "3225cc6c-2574-41e9-b8fd-e5e703a9dd7c"
```
```task
id: T07
title: "Repo filter dropdown on Token Cost page"
status: done
priority: high
description: >
Add a "Filter by repo" select element directly below the Token Cost page
heading (above all three tables). Populate options from the already-fetched
/repos/ data (plus an "All repos" default at the top). When a repo is selected,
re-render all three tables (By Repo, By Workplan, Top Tasks) showing only rows
whose data is attributed to that repo. The individual-events data (fetched for
wsMap/taskMap) is already keyed by repo_id on each event — use that to filter
By Workplan and Top Tasks rows client-side. By Repo always shows at most the
selected repo (one row). If "All repos" is selected, all rows are shown as
before. Implement filtering as a reactive variable that triggers table redraws.
state_hub_task_id: "c01cead4-ff9c-4533-b3d8-9f7554387771"
```
```task
id: T08
title: "Sort order dropdown on Token Cost page"
status: done
priority: high
description: >
Add a "Sort by" select element immediately to the right of the repo filter
dropdown. Options (in display order):
"Tokens Total" (default — current server-side order)
"Tokens In"
"Tokens Out"
"Event Count"
"Most Recent"
Implement as a client-side sort applied after filtering and before slicing for
max-results. For the first four options sort descending by the named field.
For "Most Recent", sort each table's rows by the most recent created_at among
the individual token events belonging to that row's group (repo/workstream/task).
Derive a lastEventAt lookup map from the already-fetched /token-events/ data;
rows with no events sort last. The sort applies uniformly to all three tables.
state_hub_task_id: "84183245-5016-4d87-ad6a-9cd5f6873245"
```
```task
id: T09
title: "Max results dropdown on Token Cost page"
status: done
priority: medium
description: >
Add a "Show" select element immediately to the right of the sort dropdown.
Options: 10, 20, 50, 100, 500. Default: 20.
After filtering and sorting each table's data array, slice to at most N rows
before rendering. Display the total available count beneath or beside each
table when the table is truncated (e.g. "Showing 20 of 47"). The limit applies
independently per table (each table may have different totals). No API change
needed — client-side slice of the already-fetched arrays.
state_hub_task_id: "3ef43135-fb65-4cca-b8c3-4c7eeb52107c"
```
```task
id: T06
title: "Consistency gate and docs update"
status: done
priority: low
description: >
1. Run `cd state-hub && make test` — all token_events tests must pass.
2. Run `make fix-consistency REPO=the-custodian`.
3. Add a one-paragraph entry to src/docs/reference.md describing the
/data/<type>/<id> URL scheme and the REF column convention.
state_hub_task_id: "107cb5bb-ff0c-4c97-af04-2cdeff11f0b2"
```
## Amendments & Improvements
Post-completion fixes and enhancements discovered during manual testing.
### A01 — amendment — Deep-link URL prefix wrong
`ref-cell.js` generated deep-links as `<origin>/data/<type>/<id>` but
Observable Framework serves pages at `/<type>/<id>` (the `src/data/`
directory is for data loaders, not pages). Fixed by removing the `/data/`
prefix from the `deepLink` construction in `ref-cell.js`. The T01
description was also inaccurate in stating the `/data/` path.
### A02 — amendment — FileAttachment rejects template literals
T04 used `` FileAttachment(`../data/token-events/${eventId}.json`) `` in
the landing page. Observable Framework requires `FileAttachment` to be
called with a single **literal** string (it processes them statically at
compile time). This caused a `SyntaxError` that also aborted the cell
defining `eventId`, cascading into a `RuntimeError: wsId is not defined`
on the next cell. Fixed by replacing all `FileAttachment` calls in landing
pages with direct `fetch(${API}/...)` calls.
### A03 — amendment — Workstream and repo landing pages missing
T04 only created a token-event landing page. The By Workplan and By Repo
tables also had REF links (to `/workstreams/<id>` and `/repos/<slug>`),
both returning 404. Added:
- `src/workstreams/[id].md` — fetches `GET /workstreams/{id}`
- `src/repos/[slug].md` — fetches `GET /repos/{slug}/`
- Corresponding unused data loaders left in `src/data/` for reference.
### A04 — amendment — Repos router uses slug, not UUID
T05 passed `repo_id` (UUID) to `refCell` for the By Repo table, but the
repos API uses slug-based routing (`GET /repos/{slug}/`). Passing a UUID
returned 404. Fixed by passing `repo_slug` to `refCell` so the deep-link
resolves correctly.
### A05 — amendment — Top Tasks refCell used wrong record type
T05 specified `"token-events"` as the record type for Top Tasks, but the
column contains `task_id` (a task UUID, not a token-event UUID), so the
landing page returned "Token event not found". Fixed by:
- Changing record type to `"tasks"` in the `refCell` call.
- Creating `src/tasks/[id].md` (fetches `GET /tasks/{task_id}`).
### I02 — improvement — Entity FK fields on detail pages link to their targets
On every detail page (token-event, task, workstream, repo), fields that hold
a foreign-key UUID (`task_id`, `workstream_id`, `repo_id`) now render as
clickable links with an async-loaded bubble-help showing the entity title.
Implementation:
- `GET /repos/by-id/{repo_id}` added to `api/routers/repos.py` — UUID
lookup needed because the existing repos router uses slug routing.
- `FIELD_LINKS` registry added to `src/components/field-help.js` mapping
each FK field to `{apiUrl, getUrl, getTitle}` resolution rules.
`getUrl` receives `(id, data)` so slug-routed entities can derive their
page URL from the fetched entity (e.g. `repo_id` → `/repos/{data.slug}`).
- `_linkCell(key, id)` helper added: renders a short-UUID link immediately,
then fetches the entity asynchronously and wraps the anchor in a
`<help-tip label="{title}" description="{type} · {full-uuid}">` once
the data arrives.
- `fieldRow` updated to dispatch to `_linkCell` whenever the field key is
in `FIELD_LINKS` and the value is non-null.
### I05 — improvement — Max results dropdown
A "Show" select (10 / 20 / 50 / 100 / 500, default 20) sits to the right of
the sort dropdown. After filtering and sorting, each table is sliced to at most
N rows. A "Showing M of N" note appears below any truncated table.
### I04 — improvement — Sort order dropdown
A "Sort by" select sits to the right of the repo filter. Options: Tokens Total
(default), Tokens In, Tokens Out, Event Count, Most Recent. Sorting is applied
client-side after filtering and before the max-results slice. "Most Recent"
sorts by the maximum `created_at` among the events in each group, derived from
the already-fetched individual events data.
### I03 — improvement — Repo filter dropdown
A "Filter by repo" select appears directly below the Token Cost heading. Options
come from the already-fetched `/repos/` list plus "All repos" at the top.
Selecting a repo filters all three tables client-side to show only rows
attributable to that repo. No API change needed.
### I01 — improvement — Workstream and task Name columns show titles
T05 originally showed truncated UUIDs in the Workstream and Task name
columns (the summary data carries only IDs, not titles). Improved by
fetching `/workstreams/` and `/tasks/` in parallel with the token-event
poll and building lookup maps (`wsMap`, `taskMap`). The Name column now
displays `workstream.title` and `task.title` (truncated to 80 chars) with
the full UUID as tooltip.