A visually compelling, on-brand, live triage cockpit for PureTensor Task
Intelligence (pt). Surfaces pending tasks and critical issues ranked by the
server's composite priority_score, with four runtime-switchable themes.
Live: https://ptask.tail07f9ef.ts.net (tailnet-gated; no login)
A read-only Python stdlib sidecar that runs alongside the canonical pt serve.
It reads the same ~/puretensor-tasks/tasks.db read-only
(WAL ⇒ safe concurrent reads — zero risk to the canonical server) and delegates
all writes to the pt binary, so the canonical mutation path is never bypassed.
No pip dependencies (stdlib only). No build step on the frontend (single
self-contained index.html, vanilla JS + CSS custom properties).
| Theme | Look |
|---|---|
| Mission Control | Dark telemetry ops board, teal→blue glow, dense (default) |
| Crystal / Glass | Glassmorphism, frosted panels, gradient, crystal-cube motif |
| LCARS | Star-Trek ops console, blocky amber/orange/lavender (rail collapses <700px) |
| Executive | Light, minimal, whitespace |
The header domain switch is configured with PTASK_DASH_DOMAINS as
comma-separated key[:Label[:ABBR]] entries. The first configured domain is
the default unless PTASK_DASH_DEFAULT_DOMAIN selects another key. Every card
wears a clickable domain chip that advances through the configured list by
persisting an explicit domain: label through pt edit --label/--unlabel;
the composer offers AUTO plus the same list.
With PTASK_DASH_DOMAINS unset or blank, the cockpit retains its legacy
ALL / ENG / MGMT switch and deterministic classifier: explicit
domain:eng/domain:mgmt label > project map > label map > title keywords >
ENG. Switching remains a pure client-side re-render across every panel.
Rules the 2026-07-06 audit added after measuring 276 contrast failures across
the four themes (worst: Exec board — P3 HIGH lane label at 1.27:1, heatmap
counts at 1.3:1; Mission statusbar/ages at 2.6-2.9:1):
--ink— the only text color allowed on saturated fills (priority badges/chips, severity pills, gradient buttons). Dark ink passes ≥4.5:1 on every fill and both--gradendpoints; white never did on P1/P2/P5/teal.--p5t…--p1t,--accent-t,--hdr-muted,--live— text variants: same hue as the fill tokens but readable as text on the theme's surfaces. Dark themes alias them to the fills; Exec overrides with darkened versions. Any new "priority-colored text" must use--pNt, never--pN.--heat-base— per-theme opaque base the heatmap cells blend against so cell ink can be luminance-picked (renderHeat), re-rendered on theme switch.--dimmust stay ≥4.5:1 on--bg2/--panel2; it is real content (ages, status, hints), not decoration.- Radii scale is 3-step: 14 (panels) / 10 (cards, inputs) / 6 (chips, badges,
small buttons). Type floor is 10px. Control icons are the inline SVG sprite
(
#i-checketc.), never emoji; toast text may keep emoji. - Themes are costumes over the same bones: a skin may never cost correctness (contrast, overflow, touch targets) at any width — LCARS drops its rail and elbow padding below 700px for exactly this reason (320px reflow).
- Event delegation, no inline handlers. Rendered controls carry
data-act(+data-id/data-uuid/data-n/data-p); two document-level listeners (click + Enter/Space) dispatch every action. There are no inlineon*attributes in rendered HTML — the page is CSP-clean and the 15s re-render doesn't reattach hundreds of handler strings. Add a new control by giving it adata-actand a branch in the click delegate, never an inlineonclick. - Focus return survives the re-render: overlays remember their invoker by
a stable
[data-act][data-id]selector and refocus the re-rendered twin. - Modal drawer. The detail drawer is a real
aria-modaldialog — scrim dims + blocks the board, click-scrim / Escape / close-button all dismiss, Tab is trapped inside. Timeline points open it too (data-act=drawer). - Phone header (<640px): the five count chips collapse to one compact
summary line (
#chips-c); only one of#chips/#chips-cis displayed so screen readers read the counts once. The capture bar keeps its own full-width row — the dictation control is never the thing that gets shrunk. - The capture result card is a
<div>, not a<section>. The phone shell hides everymain > sectionbut the active pane; the receipt for a task you just dictated has to survive that whichever tab you are on.
- Header — crystal-cube mark, live UTC clock, count chips (crit / urgent /
overdue / due≤7d / open), then the full-width capture bar: press to
dictate, and the task is transcribed, classified ENG/MGMT and created in one
round trip, with a result card carrying the PT-id and an Undo. Beside it the
small ⌨ + button (or the
nkey) opens the task composer (title + description + severity + domain + optional deadline, with its own 🎤 speak-to-fill for the review path). Domain switch + theme switcher sit on the row below. - Critical Now — top tasks by composite
priority_score(raw P-level as a badge), pulse/glow animation on newly-arrived criticals - Priority Lanes — P5→P1 columns, score-ranked within each, inline done button
- Deadline Timeline — dated pending tasks on a horizontal NOW-anchored axis
- Neglect Heatmap — pending tasks by priority × age bucket (the real "what's been sitting" signal)
Designed for Safari "Add to Home Screen" on an iPhone 17 Pro Max (440×956 CSS
px, safe areas 59 top / 34 bottom): viewport-fit=cover + black-translucent
status bar, safe-area padding on the header, tab bar, drawer and composer, a
180px apple-touch-icon.png and PNG manifest icons. The tailnet is the only
access gate; the sidecar does not challenge the browser.
- Two-row header: brand · crit/urg/overdue counts · + ; full-width ALL/ENG/MGMT switch. The theme switch moves into the More pane.
- One pane at a time behind a fixed bottom tab bar — Critical (with a P5 badge) · Lanes · Recent · Review · More (timeline + heatmap + appearance). The pane and lane choice persist in localStorage.
- Lanes show one priority at a time via a sticky P5…P1 picker; the page scrolls, nothing scrolls inside anything else. Recent loses its 340px box.
- Detail drawer → bottom sheet; composer → top-anchored sheet with 16px inputs (no iOS focus-zoom); hover lifts are disabled where hover doesn't exist.
- Resuming from the background refreshes and re-opens the SSE stream.
An edit failure stops processing before a requested priority change. The
sidecar still delegates fields and priority to separate CLI invocations for
compatibility, so a later pt priority failure cannot roll back an earlier
successful pt edit. The Rust dashboard API applies the entire edit in one
transaction.
| Method | Path | Notes |
|---|---|---|
| GET | /healthz |
systemd/tunnel probe |
| GET | /api/config |
public dashboard title, domains, default domain, and version |
| GET | /api/stats |
counts, throughput, overdue, due≤7d |
| GET | /api/tasks?status=&limit= |
tasks + scoring fields + project + labels (v0.12) |
| GET | /api/critical?limit= |
top pending by priority_score |
| GET | /api/timeline |
pending tasks with a deadline |
| GET | /api/heatmap |
priority × age-bucket matrix |
| POST | /api/tasks/<id>/done |
shells pt done <id> |
| POST | /api/tasks/<id>/snooze {days?} |
shells pt snooze <id> "<days> days" (v0.12) |
| POST | /api/tasks/<id>/dismiss |
shells pt dismiss <id> (v0.12) |
| POST | /api/tasks/<id>/reopen |
shells pt reopen <id> (v0.12) |
| POST | /api/tasks/<id>/edit {title?, description?, priority?, deadline?, labels_add?, labels_remove?} |
shells pt edit (+ pt priority for level); null deadline clears (v0.12) |
| POST | /api/tasks {title, description?, priority?, deadline?} |
shells pt add [--priority=] [--description=] [--deadline=] -- "<title>" |
| POST | /api/voice (raw audio body) |
Whisper STT → Bedrock Claude draft → {transcript, fields:{title,description,priority,deadline,labels,domain,reason}} to pre-fill the composer |
| POST | /api/voice/task (raw audio body) |
The same pipeline, then pt add — the header capture bar's one-press path. Returns {ok, pt_id, id, transcript, fields, stt, llm}. Rejects silence and Whisper artefacts with ok:false and creates nothing |
# against a copy of the DB (never the live one for dev)
PTASK_DB=/tmp/tasks.dev.db PTASK_DASH_BIND=127.0.0.1:9519 python3 server.py
# open http://127.0.0.1:9519/ (no login; production is reached only on the tailnet)| Var | Default | Purpose |
|---|---|---|
PTASK_DB |
~/puretensor-tasks/tasks.db |
SQLite path (opened read-only) |
PTASK_BIN |
~/.cargo/bin/pt |
pt binary for write delegation |
PTASK_DASH_BIND |
127.0.0.1:9510 |
bind address (loopback; production sets this to the tailnet) |
PTASK_DASH_TITLE |
PTASK |
header, browser, and home-screen title |
PTASK_DASH_DOMAINS |
(unset) | comma-separated key[:Label[:ABBR]] list; blank keeps legacy ENG/MGMT mode |
PTASK_DASH_DEFAULT_DOMAIN |
first configured key | domain assigned to tasks without an explicit configured domain: label |
PTASK_DASH_WWW |
./www |
static dir |
PTASK_ACTOR |
dashboard |
actor stamped on dashboard-originated pt writes |
PTASK_STT_URL |
http://127.0.0.1:9000/transcribe |
voice STT endpoint (local Whisper); accepts -F audio=@ |
PTASK_VOICE_MODEL |
us.anthropic.claude-haiku-4-5-20251001-v1:0 |
Bedrock model for voice→task extraction |
PTASK_VOICE_REGION |
$AWS_DEFAULT_REGION or us-east-1 |
Bedrock region (keyless, IAM via ~/.aws) |
PTASK_AWS_BIN |
/usr/local/bin/aws |
aws CLI path for Bedrock invoke |
PTASK_VOICE_FALLBACK_URL |
http://127.0.0.1:8600/v1/chat/completions |
local vLLM fallback if Bedrock errors |
PTASK_VOICE_FALLBACK_MODEL |
nemotron-lightning |
fallback model id |
Two by-hand end-to-end checks (not in CI — each needs a browser download). Both boot their own sidecar on a spare loopback port, so neither touches the live dashboard.
dashboard/tests/e2e/severity-order.sh # the board renders severity-ordered
dashboard/tests/e2e/domains.sh # configured-domain tenant switchseverity-order.sh serves a copy of the task DB and asserts, in a real
Chromium, that the Critical panel never shows a lower severity above a higher
one, that /api/tasks returns severity-first by default, and that each
priority lane holds only its own band. Run it after touching TASK_ORDERS,
q_tasks, or the board's render path.
# One-time: create a clean deployment worktree. Do not serve from the active
# development checkout at ~/ptask; branch switches there must not change live code.
git -C ~/ptask fetch origin main
git -C ~/ptask worktree add -b deploy/ptask-dashboard \
~/worktrees/ptask-production origin/main
# Later releases: fast-forward only after the canonical main commit is merged.
git -C ~/worktrees/ptask-production pull --ff-only origin main
# optional per-instance knobs (title, domains, bind, PTASK_ACTOR) — not a login secret
# ~/puretensor-tasks/.dashboard.env is still read if present; PTASK_DASH_PASS is ignored.
# user service
cp ~/worktrees/ptask-production/dashboard/ptask-dashboard.service \
~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now ptask-dashboard
loginctl enable-linger "$USER" # survive logout
curl -s localhost:9510/healthz # -> OKPublished as a Tailscale Service at https://ptask.tail07f9ef.ts.net. Serve
terminates TLS and reverse-proxies plain HTTP to this sidecar. Network reach
on the tailnet is the access decision.
systemctl --user disable --now ptask-dashboard
rm ~/.config/systemd/user/ptask-dashboard.service
# the Tailscale Service is the remaining publish pathThe canonical pt serve and tasks.db are never modified — nothing to revert there.
- Ranking uses composite
priority_score, not raw P-level (P5 is inflated: 48 tasks). - Neglect is derived from task age (
created_at); the DB'sscore_neglectcolumn is currently unpopulated. - The heatmap groups by priority × age bucket because
cluster_keywordsare currently stopword noise, not useful topic labels. - Dependency DAG is deferred to v2 (
depends_onis empty across all tasks).
-
v0.21.0 — Tailnet is the only gate. Removed the login shell, session store,
/api/auth/*, HTTP Basic, failed-login throttle, Face ID unlock (face-unlock.jsand its tests), and the non-loopback password requirement./loginand/logoutredirect to/. CSRF Origin checks remain for state-changing POSTs and accepthttps://ptask.tail07f9ef.ts.net.PTASK_ACTORis unchanged. LeftoverPTASK_DASH_PASS/PTASK_DASH_USER/PTASK_DASH_SESSION_STORE/PTASK_DASH_SECURE_COOKIEenv vars are ignored. -
v0.20.1 — The login throttle keyed on the TCP peer, which behind the cloudflared tunnel is the connector for every internet client: five bad guesses from anyone locked the operator out.
Cf-Connecting-Ipis now believed fromPTASK_DASH_TRUSTED_PROXIESonly (the v0.17.1 note below described this as already true; it was not). A JSON body that is not an object (or not UTF-8) gets a 400 instead of a dropped connection. A date-only deadline is due all day (no "0d over" on the due date)./api/streamemitschangewhen the journal grows, not on a blind 15 s tick. -
v0.17.1 / pTask v3.22.1 — The failed-login throttle is bounded. Its key is the caller's own address (
Cf-Connecting-Ipbehind Cloudflare), so a host with a routed IPv6 /64 could mint unlimited keys, and the table was swept with an O(table) rebuild on every attempt — enough for one client to pin the dashboard's event loop. The table now caps atLOGIN_MAX_RECORDS, sweeps on a 30 s timer instead of per request, and evicts unlocked clients before locked ones so a flood cannot forgive a lockout. Separately,hmac.compare_digestraises on a non-ASCIIstr, which turned a wrong password containing an accent into a 500; both comparisons now encode first. -
v0.17.0 / pTask v3.22.0 — Face ID fixes from the 2026-09-03 adversarial review. The enrol checkbox ships
checkedand only its wrapping label is hidden, so gating enrolment onbox.checkedalone re-enrolled on every password sign-in — minting a fresh platform passkey each time and orphaning the previous one in the keychain with no way for the app to remove it. Enrolment is now gated on anenrolOfferedflag set fromisSupported(), so visibility and intent cannot disagree. A Forget Face ID on this device control was added: a passkey deleted on the device is reported by WebAuthn as an ordinary cancel, by design, so nothing automatic can recover it and the record would otherwise stay valid forever with enrolment never re-offered. The recovery code list moved into the shared core (RECOVERABLE_CODES/isRecoverable) because the three wirings' hand-written copies had drifted. The core now refuses a degenerate PRF secret (must be exactly 32 bytes and not constant). Tests: 22 contract + 6 markup + a 24-check e2e, with new cases for the re-enrol regression, the forget control, and two properties that were previously unfalsifiable — deletinguserVerification: "required"or the rpId binding check used to leave every test green. -
v0.16.0 / pTask v3.21.0 — Face ID unlock, replicated from pureKEY (
forzieri). On a device with a platform authenticator the auth gate offers Remember this device with Face ID; a WebAuthn PRF secret (rpId= the serving hostname, user verification required, resident key) derives a non-extractable AES-GCM key that wraps the dashboard password in IndexedDB, and the next visit shows Unlock with Face ID instead of the keyboard. The server surface is unchanged — Face ID replays the same password to the samePOST /api/auth/login, with the same 250 ms delay, 5-failure lockout and opaqueptask_sessioncookie. The enrolment is refused, never repaired: a different hostname or origin, an unknown record version, a tampered wrap, or a401from the login route drops it and the password becomes the only way in; a429or an unreachable server keeps it.www/face-unlock.jsis the crypto core, vendored byte-identical into pNOC and pSCOPE, with 16 contract tests intests/face_unlock.test.mjs. Storage is IndexedDB, never localStorage. Verified end to end bytests/e2e/run.sh, which boots a throwaway sidecar and drives a real Chromium with a CDP virtual platform authenticator through enrolment, unlock and the 401 refusal (kept out of CI only because it needs a browser download — run it after touching the auth gate). Not available on the Rustpt servedashboard route, which still authenticates with HTTP Basic and so has no login endpoint to replay a password to. -
v0.15.0 / pTask v3.20.0 — dictation-first capture. A full-width record bar is the first control in the header on every width (the typed
new task…box is gone; the composer is now the+button beside it, or thenkey). One press records, andPOST /api/voice/tasktranscribes, classifies and creates the task in a single round trip — the result card below the header shows the PT-id, the ENG/MGMT hemisphere with the model's reasoning, priority and deadline, and offers Undo (a reversible dismiss, never a hard delete). The extractor now also returnsdomainandreason: an explicit hemisphere is written as adomain:eng/domain:mgmtlabel so the classification is a stored fact, and when the model declines to choose, the cockpit's existingdomainOf()heuristic decides as it always has. A transcript guard rejects silence, sub-three-word fragments, non-Latin drift and known Whisper idle-hallucinations before anything is written — the composer path could rely on a human reviewing the draft, the create path cannot. The local extraction fallback pointed at127.0.0.1:8772/mistral-medium-3.5, a seat that had stopped listening, so a Bedrock outage degraded silently to a raw-transcript title; it now targets the sovereignnemotron-lightningseat on127.0.0.1:8600with thereasoning_effort: "none"that model requires, and raises instead of returning empty content. -
v0.14.1 / pTask v3.17.1 — production now runs from a dedicated, fast-forward-only worktree instead of the active development checkout or the archived standalone dashboard repository. This prevents branch switches and merge trains from changing the live cockpit source.
-
v0.14.0 / pTask v3.17.0 — PT-VE login parity: the public shell presents a branded password form instead of a browser Basic-auth prompt, exchanges the dashboard password for an opaque 24-hour HttpOnly session, persists only session-token hashes across restarts, supports explicit logout, and throttles repeated failures (five attempts, five-minute lockout). Existing Basic auth remains compatibility-only for non-browser consumers and is never challenged.
-
v0.9.1 — state-changing sidecar routes reject browser requests whose
Origindoes not matchHost, preventing cached Basic credentials from authorizing cross-origin task or voice mutations. Header-free clients such ascurlremain compatible. -
v0.9.0 — provenance re-classified by intent, not by which process ran the INSERT. Two classes: robot (auto-generated —
distilled,incident,subtask_promotion,specola) and human (everything else: the operator typed/dictated it, OR Claude Code/HAL created it on the operator's request —manual/voice_memo/telegram/claude_code/mcp/remote-cli; unknown → human). This flipsclaude_code+mcp+remote-clifrom robot to human, so the board is no longer all-bot. Fluxadded_machinerenamedadded_robot;ROBOT_SOURCES(Rust/Python) +AUTO_SRC(JS). -
v0.8.0 — the flux chip is now a range picker: click it for a dropdown of 30m / 1h / 6h / 24h / 7d.
/api/statsreturnsflux.by_window(added, added_human, added_machine, done for every window) plusflux.windows, so the cockpit switches range as an instant local re-render off the last payload — no refetch. Selection persists inlocalStorage. Both servers changed (Rustpt serve3.3.0 + Python sidecar 0.8.0); the flatadded_24h*fields are replaced by the windowed object. -
v0.7.0 — provenance + flux: every card wears a subtle
PT-xxxxtag with an origin glyph (person = operator-enteredmanual/voice_memo/telegram, bot = machine-generated:claude_code,mcp,distilled,incident, …); the drawer spells the origin out. Header gains a +N/−N 24h flux chip (tasks added vs completed in the last day, added split operator/machine in the tooltip) backed by new/api/statsfieldsadded_24h,added_24h_human,added_24h_machine,done_24hin both servers. -
v0.6.1 — accessibility polish: interactive task titles in Critical Now and Priority Lanes now expose at least a 24px hit target with visible focus rings.
-
v0.6.0 — 🎤 speak-to-fill voice capture in the composer. A mic button records (MediaRecorder) and POSTs the clip to
POST /api/voice, which runs the fleet's local Whisper (large-v3-turbo, STT) then AWS Bedrock Claude Haiku 4.5 to draft a clean title, description, severity (P1–P5) and deadline from the spoken note — the operator just reviews and hits Create. Bedrock is keyless (IAM via~/.aws); local vLLM is the fallback. Mic needs a secure context, so the UI guides http-IP users to the HTTPS host. STT/LLM endpoints are env-overridable. -
v0.5.0 — full task composer replaces the one-line quick-add: the header control now opens a modal with title, description, a five-pill severity selector (P5…P1), and an optional deadline.
POST /api/tasksaccepts{title, description?, priority?, deadline?}and shells them topt addas explicit--priority=/--description=/--deadline=flags with a--title separator (hyphen-safe; inline@label/#project/~2hon the title still parse). Fast capture preserved — type a title, Enter, ⌘/Ctrl+Enter to create. -
v0.4.0 — direct severity picker + done-confirmation dialog on the dashboard.
-
v0.3.0 — scrollable priority lanes surface every pending task (no +N dead-end).
-
v0.2.1 — clamp API
limitquery parameters so negative SQLite limits cannot become unbounded reads; dashboard unit tests now run in CI. -
v0.2.0 — promote/demote priority controls (▲/▼ steppers) on Critical-strip and lane cards. New
POST /api/tasks/<id>/priority {level:1..5}endpoint shellspt priority <id> <level>(requirespt≥ 1.2.0), which re-runs scoring so the composite ordering updates immediately. -
v0.1.4 — Critical Now strip shows the top 12 by composite score (was 9).
-
v0.1.3 — fail closed on non-loopback binds without auth, add security headers, cap POST bodies, harden the systemd user unit, and add accessibility landmarks / reduced-motion handling.
-
v0.1.2 — fix done buttons to use
pt_id(PT-N) rather than task UUID. -
v0.1.1 — proper LCARS elbow-frame styling (shoulder headers, pill rail, blocky asymmetric panels), scoped so the other three themes are unchanged.
-
v0.1.0 — initial triage cockpit (4 themes, live poll, critical strip, lanes, timeline, neglect heatmap).