Skip to main content

MVP surface (STACKS_SURFACE=mvp)

The MVP surface is a second deployment of the same build that carries only one loop: align a live agent session to a work layer, push typed artifacts, and read the result on the boards and the work surface.

Two manifests decide what it carries, and they are the only two:

  • What renderssrc/lib/surface.ts. Pruning here is presentation only: hidden workspace and account routes redirect to the workspace home rather than 404, so nothing is reachable-but-half-rendered.
  • What the MCP mount servessrc/lib/mcp/tools-product.ts. The MVP advertises a declared, frozen subset of the platform's tools: the task-manager's own surface (workspaces and membership, boards, columns, labels, tasks and links, comments, subtasks, attachments, projects, artifacts, connected agent sessions, token context) and no agent-operations-plane tools. The subset is frozen by tests/mcp/product-surface.test.ts, which also asserts every product tool is definition-identical to its platform twin — so the two surfaces cannot drift into two dialects of the same tool.

On board kinds, be precise. All five kinds — TASKS, CRM, SUPPORT, BUGS, ROADMAP — can be created and converted here, from the UI and (for creation) over MCP; their sidecar fields render on the card. What the MVP does not carry is the tooling around a kind: no deal, ticket, bug-report, or initiative MCP tools for an agent to call, no MCP tool for converting a kind (the board menu action still works), no kind dashboards, and no GitHub issue sync or delivery tools. BUGS matters in particular — it is the board jentrix push findings --mint-issue files into, so an Issues board earns its keep here.

Turning it on

Set STACKS_SURFACE=mvp on the deployment. Any other value — or no value — renders the full surface unchanged. The intended shape is a second Vercel project on the same repository and branch with its own database; the full deployment is untouched. Operator detail — what the variable narrows, and which cron jobs and env vars each shape needs — is in Deployment → Surface modes.

What the MVP shows

Home (boards), My Work (opening with Awaiting your review), Work (the layer view), Projects, Setup, Sessions, Search, Inbox, and Settings reduced to the controls an MVP operator can actually change. Every other workspace route redirects to the workspace home — hidden pages are unreachable, not just unlisted. The command palette follows the same allowlist.

The account area follows the same manifest: Profile + API tokens only. /account/agents redirects to /account, so the full agent-profile surface is no more reachable there than a hidden workspace route is.

Settings, in full: workspace name/slug, the Members entry point, one Credentials section, the notification-email toggle, theme, and (owner only) delete. Two rules hold it there. A settings section that links to a path the current surface GUARDS is not rendered on that surface — so nothing dead-ends into a redirect. And the three credentials are explained once, in the operator's terms, rather than as two sections both called "tokens":

  1. The CLI's own login (jentrix login, run by the installer) — a rotating OAuth token the CLI stores and refreshes. Most operators need nothing else, and an exported copy of it dies at the next rotation.
  2. A personal access token (/account/tokens) — only for a headless checkout that cannot approve a consent screen; the --pat path.
  3. Workspace-pinned tokens (admin) — restricted to this workspace.

Setup (/[workspace]/setup)

The one surface a new operator meets. It is a single ordered command sequence, not a wizard: five numbered clauses, every one visible at once, in the order they are run in a project folder — 01 INSTALL, 02 ALIGN, 03 CONNECT, 04 PUSH, 05 END. Each clause carries one sentence of consequence, one copy-once command block, and one evidence line stating what this deployment has or has not observed.

Two properties make it worth trusting:

  • Nothing to fill in. No rendered command contains a placeholder — no bracketed hint, no ${VAR}, no token prefix. This is mechanical, not editorial: a unit test scans every command the surface can emit for [, <, ${, YOUR_, PASTE, tm_, tmo_, tmr_ and fails on a hit. The installer URL is resolved server-side from NEXT_PUBLIC_APP_URL, so the page always names the deployment you are looking at.
  • Step state is a QUERY, never a flag. There is no setupCompleted column, no checklist table, no localStorage. Each clause reads durable rows the platform already writes: a used ApiToken; an AgentSession carrying an alignment snapshot; an OAuth grant for a registered client; a non-archived, non-TRACE Artifact; a COMPLETED session. Nothing to reset, migrate, or repair when an operator reinstalls or switches machines.

The install clause never shows a green check, by construction. The server can prove that authentication happened; it can never prove the toolchain is on this machine. That clause therefore says so in its own words and offers jentrix --version / jentrix whoami instead of a state it has not earned.

Setup advances live over the workspace channel, and converges without Pusher — realtime is the optimization, a bounded poll (at most one request per 10 s, stopped once every server-provable step is satisfied and stopped while the tab is hidden) is the mechanism.

The page renders identically in both surface modes; the commands are the same commands and the rows are the same rows.

The align → push → end loop

  1. Align — from inside a live Claude Code session run /jentrix-align, or from Codex run $jentrix-align (jentrix align remains the terminal fallback). The wizard asks a fixed question set — workspace, project, project name, work item, task title, owner, confirmation — detecting everything it can (repository, branch, provider, project candidates via repository discovery). Every question is a choice selection except two: a new project's name and a new task's title are free-text questions the OPERATOR answers — the agent never invents a board-permanent label. The question phase also runs the doctor's preflight checks up front (runner, spool, repo link), so a blocker surfaces before the first question, and the confirmation card itself enumerates every side effect submitting will perform (project/board/task creation, repo linking, the provider-session binding and the provider evidence source the host will watch, session-host start, capture mode, the ./.mcp.json write) plus any degradation notices — approval covers exactly that list. The server confirms the alignment and returns a snapshot (ids + display names) that the agent echoes verbatim; it is stored on the session and shown on the session page. Re-running the command shows the current alignment and offers re-align — a re-align is always visible in the activity feed, never silent. Every task-aligned stretch is also a row in the session's alignment interval history (task-performance-monitoring PRD §5): a task change closes the open interval and appends the next one, a same-task re-align updates it in place, closing the session closes it, and the session's taskId is a same-transaction mirror of the open interval — so "which task was this session working on, when" is answerable after the fact (get_agent_session returns the history as alignments[]). Spend before the first align, or while unaligned, is first-class UNATTRIBUTED — a rendered state, never a silent omission. Re-aligning to the SAME project converges on the existing session (task/owner/capture changes ride it); pointing the provider session at a DIFFERENT project requires ending the current session first (jentrix session end) — the wizard warns up front and the refusal names that fix. A non-interactive same-target re-align needs only --project <same-id> --yes: with no --task/--new-task/--session-level flag it keeps the current alignment's work item and owner and says which ones it kept. The full flag set is still required for a genuinely fresh submit — a different project, or a checkout with no live alignment.

  2. Push — at any point, jentrix push plan|decision|findings|report|diff|deliverable|learning|prompt|goal|prd [file] (stdin when no file) stores a typed artifact linked to the session and its aligned project/task. Content is redacted locally before it leaves the machine and again server-side at ingestion. Each kind lands in exactly one work layer via a pure rule table — Direction (decision/goal/prd), Planning (plan/prompt), Execution (findings/report/deliverable), Delivery (diff), Learning (learning); unknown kinds read as Execution. The last three are the session's inputs (STA-26): /jentrix-plan pushes the opening prompt verbatim as prompt and the approved goal as goal, and a PRD, when one exists, is pushed as prd — so the record answers what was this session asked to do with the same first-class evidence as what it produced.

  3. End/jentrix-end prompts the agent to move the aligned task to a terminal column and push the final report and any unpushed decisions/learnings, then jentrix session end completes the session. Telemetry (4-way token split + agent time) is recorded by the session host's attested path from provider receipts — never estimated, and honestly "not reported" when absent. end exits 0 for a clean or capture-off close and 8 when it closed the session but recorded a capture gap; every other code means the close did NOT happen (see exit codes). The session is sealed at the close: jentrix push refuses it with SESSION_NOT_ACTIVE, by design. Closing facts therefore live in the auto-written RUN_SUMMARY, which is built after telemetry is known and names the four-way split (and an unattributed close, when there is one).

    The close also stores the session's final response as its own artifact — a report (Execution layer) titled Final response — session …. It is the last assistant message the session host observed, pushed on the ordinary typed-push boundary before the session is sealed, since COMPLETED refuses pushes. This is deliberately not part of the RUN_SUMMARY: that document is a deterministic server projection and model prose is banned from it, so the output rides beside it and says so in its own header. Two rules follow from honesty: it is stored even when TRACE capture is off (that is the case it exists for — capture-off is the MVP default, and without it a closed session keeps its telemetry and loses what it concluded), and a session where no assistant text was observed gets no artifact at all rather than an empty one. A refused or failed push never costs the operator their close; the closing log line names it as output not observed.

Who produced it

Every contribution answers which producer made this. Two facts do it, and neither is a registry lookup — the AgentProfile registry is the parent deployment's plane under the 2026-08-13 product split, and the child records a loose label with no FK.

  • The model the provider actually ran. Claude Code stamps model on every assistant transcript entry, and the session host already tails that file, so the session records it for free. It rides on the host's existing 30-second heartbeat — no new timer, no new route, no new tool — and is host-attested by construction: the heartbeat route writes only the authenticated operator's own open session. An observation that names a model REPLACES the stored one (a session may switch models mid-flight, and the last model that ran is the one that ran); an absent observation leaves it alone, because a quiet heartbeat window must never erase what the transcript already proved. Nothing observes a model for Codex sessions yet, and that absence renders as absence.
  • An optional producer label. jentrix align --agent "<name>" [--agent-emoji <e>] labels the session; jentrix push --agent "<name>" labels one push, which is what makes a fan-out of subagents through a single session legible as separate producers. A push inherits the session's label when it does not override it. A task-addressed push (--task, no session) inherits nothing and stays unattributed — there is no session, so there is no producer to claim.

The label is a write-time snapshot on the artifact, following the same rule as MCP token identity in activity payloads: the badge survives a later relabel or deletion of the session that produced it.

No label means the operator themself, and the surfaces say so — a person glyph and the operator's own name, never "unknown agent". That distinction is the point: "nobody declared a producer" and "we could not identify the producer" are different facts, and only one of them is true here. The wizard discloses which one applies on every alignment, including the default, as one added line on the confirmation card. The seven questions and their order are unchanged; lines may be added, questions never.

The chip renders on the task panel's artifact rows and Sessions section, and on the sessions list and detail pages — one shared component, not a per-surface reimplementation.

What it cost, on the card

Tokens are not spend. The gap report measured 99.4% of recorded input as cache reads, which cost a tenth of uncached input — so a raw token count and a cost differ by more than an order of magnitude, and only the priced number is a decision input.

  • The price table is code (src/lib/model-pricing.ts), keyed by the model id the provider reported, with four rates per entry and an asOf date on every one. It is deliberately not configuration: a price an operator can edit in a settings page is a number nobody can audit later, and every cost this product renders is a claim about money.
  • Matching is EXACT. An unknown model — including a future one whose name merely starts like a known one — yields UNAVAILABLE, never zero and never a nearest-tier guess. UNAVAILABLE renders as that word, because a dash on a surface full of dashes reads as zero.
  • The arithmetic uses the disjoint split. inputTokens is the TOTAL and the two cache figures are subsets of it, so uncached input is the subtraction and each of the four is priced at its own rate.
  • Cost renders next to tokens everywhere tokens already render: the board card badge, the task panel's Sessions section, the sessions list and detail, /[ws]/work, the workspace home, and the project page.
  • Rollups exist at the three altitudes decisions are made — task card, project, workspace week — all derived at read time from existing rows. No aggregate table, no cron.
  • Every aggregate declares its coverage, in two fractions. "3/5 sessions reported usage · 2/3 priced". They are different holes — a session with perfect telemetry on an unknown model is reported but unpriced — and one number would hide whichever is smaller.
  • One caveat is stated rather than hidden: the session rollup records a single cacheCreationTokens figure and does not distinguish the 5-minute from the 1-hour cache TTL, which the provider charges differently. Cache writes are priced at the 5-minute rate, so a session that used 1-hour caching costs more than shown. The sentence ships on the session page.

Live spend while you can still act on it

Usage used to be transactional at close, so an ACTIVE session legitimately reported nothing while a complete rollup sat in its host's spool — the state of every running session the gap report inspected. For a fleet operator that is backwards: post-hoc telemetry is accounting, not control.

  • The receipt rides the beat that already exists. The session host posts a heartbeat at most once every 30 seconds; that body now carries the usage rollup. No new timer, no new route, no new MCP tool.
  • Host-attested, always. The heartbeat authenticates the bearer and writes only the caller's own open session, so usage can never be asserted about someone else's work. It is never a parameter on an unrelated call — the same rule every usage write follows.
  • Provisional is a separate axis from coverage. usageProvisionalAt answers is this final; usageCoverage answers how much was observed. They are different questions and share no column, because a settled number that still reads as provisional (or the reverse) is exactly the kind of quiet wrongness this milestone removes.
  • The close-time rollup stays authoritative. complete_agent_session overwrites the numbers and clears the stamp in the same write.
  • Every surface labels a live figure. The session page says PROVISIONAL (live receipt), the sessions list and board card say prov. An ACTIVE session now shows spend instead of UNAVAILABLE, and the killed-host telemetry hole shrinks to one heartbeat window.

Token budgets: a guard that records, never one that stops

jentrix align --budget <tokens> arms an optional per-session budget, and the confirmation card gains one line naming the number. A line is added; the seven questions and their order are not touched.

  • Crossing the budget writes one SESSION_BUDGET_EXCEEDED activity row and badges the anchored task's card over the existing board-event path. It does not stop the session, and the disclosure says so.
  • Both totals are checked — the live receipt AND the authoritative close-time rollup. A host that only reports at close never posts a receipt, so checking the heartbeat alone let a budgeted session close far over its cap in silence. Calling the guard from both places is safe precisely because exactly-once belongs to the insert, not to a caller.
  • The activity row is the marker. There is deliberately no budgetBreached flag: a flag needs a writer, a clearer, and a converger, and it can disagree with the ledger an operator reads. Exactly-once is a property of a WHERE NOT EXISTS insert instead. The card's own over-budget styling is derived from the same numbers, so ledger and badge cannot drift apart.
  • A breach is deliberately not a Control Tower item. That queue belongs to the full surface; here the board is the alert surface.
  • A re-align that does not mention the budget leaves it armed — the same "I did not say" versus "I said none" asymmetry the producer label follows.

Agent time is measured now, not dropped

providerActiveDurationMs and toolDurationMs were null on every row ever written until this landed. The cause was locatable rather than mysterious: the usage aggregator returns null for an empty interval list, and the only code that ever marked an interval was the Codex interactive host. The Claude transcript-tailing path — the only one the MVP uses — never marked one, so both columns were structurally null.

They are now populated from timestamps the transcript already carries, and both are measured, never derived by subtraction:

  • toolDurationMs pairs each tool_use block with the tool_result that answers it. Parallel tools each get their own interval, so the total is time spent in tools, not wall time.
  • providerActiveDurationMs pairs each assistant entry with whatever handed the provider control — the user message that prompted it, or the tool result that unblocked it. An assistant entry that calls a tool deliberately does not become the next boundary: otherwise a sixty-second tool wait would be billed as sixty seconds of generation.

An interval with no terminal event — a tool that never returned, a host killed mid-turn — is recorded unclosed, so the aggregator names the gap and degrades coverage to PARTIAL rather than reporting a quietly shorter total. An entry with no parseable timestamp yields no interval at all.

The session binding, and why it decides everything else

Alignment anchors a live provider session, and every artifact, comment and token count lands inside whichever session it picked. The id comes from the provider — never from the model.

The asking session names itself. Claude Code exports CLAUDE_CODE_SESSION_ID into every command it runs, and when it is set nothing else gets a vote. This is what lets a folder hold several open alignments at once: each session aligns itself, keeps its own alignment until it is closed, and a sibling session working the same checkout is none of its business. Directory matching cannot do that — two sessions launched in one folder match the same cwd, so both used to resolve to whichever started last, and the checkout behaved as though it held a single alignment (the second align re-aligned the first session; both sessions' pushes filed under one).

Only when a process-owned id is absent does the CLI infer the session from the plugin's trusted lifecycle hooks, matching the running directory against the cwd each SessionStart recorded. For Codex, exactly one live matching task is required; concurrent matches are refused. Three rules keep inference honest:

  • An ended session is never a candidate. The ledger is append-only, so liveness is only answerable by reading the SessionEnd records too.
  • A cold candidate is never a candidate. Freshness is max(SessionStart time, transcript mtime): a 20-hour session whose transcript was touched seconds ago is current; a 35-hour-abandoned one whose transcript still exists on disk is not. Existence was never liveness.
  • Ambiguity is disclosed, never resolved silently. When a newer live session was recorded in a directory this checkout does not sit under — Claude Code launched from one directory, the CLI run in a sibling — the session precondition warns, names that session, and gives the repair. It does not pick for you.

The transcript follows the session id, not the directory. --provider-session now carries --transcript-path's twin: an explicit id resolves the transcript its own hook record names, from any directory, and an explicit --transcript-path overrides both. Before a host starts, the transcript is checked against the session it claims to serve (Claude Code stamps sessionId on every record); a proven mismatch refuses the host with the reason, because a host on a foreign transcript reports transcriptSeen: true, matches no receipts, and closes at exit 0 with every token count null.

That last failure now announces itself. session end and session status print a Telemetry: line, and when a host ran and attributed nothing they print NO TOKEN TELEMETRY: … on stderr with the cause and the repair. It is a warning, not an exit code: exit 8 means recorded capture debt and only that, and a capture-off session with unattributed telemetry has no capture debt. The UNAVAILABLE honesty contract is untouched — nothing is estimated, absent stays absent; what changed is that absence is no longer silent.

The checkout's MCP endpoint (./.mcp.json)

Submitting an alignment writes (or updates) ./.mcp.json in the checkout root so this checkout's agent tools reach the deployment the CLI just aligned to. Without it an aligned agent's own list_workspaces / create_task / move_task answer with whatever endpoint it inherited — in the reported case production, while the alignment named a different deployment entirely.

  • Shape. One mcpServers.stacks entry with the resolved url and — by default since 2026-08-12 — no Authorization header at all. The agent authenticates with the deployment itself over OAuth.
  • No credential is ever written. Not a literal bearer, not an expanded value, and no "convenience" fallback. The file sits at a committable path, so every byte in it is treated as public.
  • Why no header. An Authorization header — even a ${VAR} template — makes an MCP client stop discovering the authorization server, so writing one switched OAuth off and left the operator needing a PAT that align can neither mint (PAT creation is a web-UI-only server action) nor export (it cannot set a variable in a shell that does not exist yet). Every aligned checkout therefore ended in a manual browser step whose failure was indistinguishable from the endpoint being wrong. Without the header the client walks the discovery chain (/.well-known/oauth-protected-resource → AS metadata), runs authorization-code + PKCE against Jentrix's own AS, gets the consent screen with scope and workspace pickers, and rotates its token on its own.
  • Approving the consent screen is the one remaining step. In Claude Code: /mcpjentrix → Authenticate. Nothing is exported, and nothing is stored in the checkout.
  • --pat restores the template (headers.Authorization: "Bearer ${STACKS_TOKEN}") for a headless context — CI, a runner, a container with no browser — that cannot approve a consent screen. There the PAT rules still apply in full: mint it at <deployment>/account/tokens with scopes read+write, export it before starting the agent, and never use the CLI's stored login token (jentrix login mints a rotating OAuth token, and an exported copy dies at the next rotation — mid-session, as a confusing 401).
  • An existing Authorization header is REMOVED, and the removal is disclosed. A header written by an earlier CLI is exactly what keeps the client off the OAuth path, so stripping it is the repair — but it is still the operator's file changing, so the confirmation card and the post-write note both say so and name --pat as the way back. Only that key is dropped; any other header the operator set survives.
  • Merge, never clobber. Unrelated servers and unrelated top-level keys are preserved. An existing jentrix entry naming a different endpoint is repointed, and both URLs are named in the confirmation card. A file that is present but unparseable is left untouched and reported as a precondition warning rather than overwritten.
  • It applies from the NEXT session. MCP servers load at session start, so the session that ran the alignment does not gain the tools — reconnect the MCP server or start a new session first. align says so; so does /jentrix-align.

Server-side prerequisite. This path only works from e6745e9 (2026-08-12) onward. Before it, the CIMD redirect_uri gate matched by exact string equality, so it refused the ephemeral loopback port every native client requests — which RFC 8252 §7.3 requires an authorization server to ignore. No MCP client had ever completed the flow against Jentrix. A deployment older than that must use --pat.

Compaction

Both provider plugins register PreCompact and PostCompact. Compaction truncates the model's context, not the log — a real compacted transcript dropped 419,184 tokens from context and zero records from disk — so at the hook the material about to become unreachable is still fully readable.

jentrix session snapshot follows the capture consent, and that is the rule that decides what it does. The alignment snapshot is a server-confirmed consent record, and capture: "off" is disclosed to the operator as "typed artifacts only, no transcript". A compaction snapshot does not get to widen that: wrapping transcript bytes in a typed artifact does not change what the bytes are.

  • Capture ON — the un-preserved byte range is copied into a typed report artifact and the offset recorded, so repeated compactions never duplicate or skip. This is what the operator agreed to.
  • Capture OFF (the MVP default) — the boundary is recorded and no content: a typed artifact naming the session, the transcript's local path and size, the trigger, and the fact that the transcript was deliberately not preserved. That the context was compacted here is the session's own lifecycle metadata, the same category as the RUN_SUMMARY.

Nothing is lost by the capture-off path that was not already the operator's choice: compaction leaves the full transcript on the machine that ran the session, /jentrix-checkpoint carries the meaning forward, and jentrix align --capture is the existing, disclosed way to have transcripts ingested.

The capture stamp is read from the LIVE host first and the alignment marker second — the marker says what was agreed, the host says what is actually running, and the honest answer is whichever collects less. An unreadable stamp reads as OFF: uploading a transcript on a guess is the one mistake here that cannot be undone.

Codex lifecycle hooks expose no transcript. Its host therefore records the compaction boundaries in the ordinary trace when capture is on, while the snapshot command uploads no transcript content.

Hooks preserve; skills distil. A lifecycle hook is a command line with a small JSON payload on stdin and no model turn: it can move bytes and cannot decide what mattered. Any claim that a hook alone satisfies "context compression must not lose meaningful context" is wrong on the mechanism. The distillation half is /jentrix-checkpoint, a model turn that writes the decisions, open questions, and next step. They compose — the skill summarizes, the hook guarantees the raw material still exists when the summary turns out to have missed something.

The handler resolves its session by matching the hook payload's transcript_path against the transcript each live session host stamped — a provable link, because the hook's cwd is the session's directory and need not be the aligned checkout. It falls back to that checkout's alignment marker for the session the payload names (never the checkout's newest — a checkout can hold several open sessions), and if neither proves a session it preserves nothing and says so: filing one session's history under another is worse than preserving nothing. Every failure path exits 0 — a snapshot must never break the operator's /compact.

How a task belongs to a project

A Task has no projectId — the column does not exist, and neither does the field on get_task. A task belongs to a board (task → column → board), and a project links boards through ProjectLink(targetType: "BOARD"), so task → board → project is the whole linkage and one board can be reached by more than one project. Alignment stores the two subjects separately on the session — AgentSession.projectId (the confirmed project) and AgentSession.taskId (the work item, validated only to be a live task in the session's workspace) — and the snapshot echoes both. Wizard-created tasks are therefore no different from any other task: reading .projectId off a task returns nothing because there is nothing to read, not because the create path skipped a field.

Transcripts are not stored for MVP-aligned sessions. TRACE capture is off by default — the session keeps only its id, alignment, telemetry, and typed artifacts. The capture machinery stays shipped; jentrix align --capture re-enables full transcript capture for a session, --no-capture keeps it off explicitly (and corrects a wrongly-on record on re-align), and the session page says "capture off" rather than showing an empty viewer. A re-align with neither flag preserves what the live session host is actually doing — the snapshot never claims more collection than is happening, and a capture change the running host cannot honor is refused rather than recorded.

Reading the result

  • Work (/[workspace]/work) — the five layer bands (Direction → Learning) with entity counts, artifact counts, and the 7-day token/time rollups by project, agent, and owner. All derived at read time from existing rows; there are no aggregate tables.
  • Boards — cards with aligned sessions carry a session chip (agent, session count, token/time total), and cards carrying artifacts carry one chip per work layer present with that layer's label and count, ordered Direction → Learning. One chip per layer, never one per artifact, so a task with nine artifacts shows at most five. Both are batched at the board query — two statements per board, neither scaling with the number of cards — and both render in either surface mode. The chips are derived from the same frozen tables the ingestion path uses, so a new push kind lights a chip with no change to the card.
  • Task detail — the Artifacts section (on Overview) lists every artifact carrying this task id, grouped by layer, newest first, with a "via session" chip where one produced it. It is scoped by TASK, not by session: an artifact pushed after its session was swept INTERRUPTED, pushed with jentrix push --task and no session at all, or uploaded with jentrix artifact upload all appear here. Each row states its own kind, size and age; its title opens the artifact viewer page (below), and the trailing Download control mints a short-lived signed URL through the existing presign path, never a URL stored in the payload. The section renders on a task with no artifacts too, carrying the one command that would produce the first. The Sessions section (on Agent work) lists each aligned session with status, capture mode, and telemetry.
  • Artifact viewer (/[workspace]/work/artifacts/[id]) — the artifact as a readable page: kind and layer, title, producer, checksum, the task/project/ session it belongs to, the tasks it is referenced on, its supersession chain, and the rendered markdown body (bounded at 500 KB; longer bodies say so and hand off to the download). The body honors the same gates as the download — a redacted stub shows the stub, unverified provenance withholds the body in the server's own words — and raw HTML in an agent-authored body is never executed. The route lives under work/ because both surface modes allow that segment, so one page serves the MVP and the full build alike.

Adding an artifact from the UI

The panel can also add one. Drop a text file on the section, paste, or pick a file: each stages the content and the kind inferred from the filename, and one control commits it. Staging is what makes the kind override real — an upload that fired on drop has already chosen the kind — and it means a dropped file cannot become a stored artifact by accident.

It is a third caller, never a second boundary. The commit calls the same ingestTypedArtifact the CLI's jentrix push calls, addressed by taskId (the --task mode above), so the two server-side redaction passes, the sha256 dedupe, R2 storage, the ARTIFACT_ADDED activity and the board event are all the shipped ones. No new storage mode was added: a binary file is therefore refused rather than mangled into text, and jentrix artifact upload remains the path for one.

Object storage is required with no default. On a deployment without it, the control renders the server's own refusal sentence — persistently, next to the control, announced — rather than failing silently.

Every object a page names is reachable from it

A page that names a real object renders it as a link, and the destination is decided by one pure resolver — entityHref in src/lib/entity-links.ts — asked per mention. The resolver consults the same manifest the route guards consult (surfaceAllowsWorkspacePath), so it can never hand out an href this surface would redirect away from; there is no second allowlist to drift.

null is a real answer and means one of exactly two things: the caller has no destination for the object (a task whose board was not resolved), or the object has no page on this surface. Both render as plain text through the shared EntityLink, never as a dead link. Two consequences worth knowing:

  • A task is addressed through its board (/[ws]/board/[slug]?task=<id>), the deep-link form the board client opens the panel from. A surface that mentions a task therefore has to resolve its board slug — the session queries do it in one batched read, never a lookup per row.

  • A decision is plain text on both surfaces. There is no decision detail route anywhere, and pointing a specific decision at the list is not a link to the object.

  • An artifact has a page — the viewer — and a download link. Titles resolve to /[ws]/work/artifacts/[id] (entityHref kind artifact); the content download stays an anchor at /api/artifacts/[id]/download, a session-authed route that authorizes (workspace role, then the shipped visibility predicate — the same check the by-id MCP read makes), presigns, and 302s with Cache-Control: no-store. Nothing stores the signed URL and no payload carries one.

    It is a plain anchor for a reason worth remembering. It was a server action whose minted URL was passed to window.open, which runs after the await and therefore outside the click gesture — a popup the browser may block. In practice the first row opened a tab, later rows silently did nothing, and the operator was left looking at the tab that had opened, reading it as every row opens the same artifact. An anchor also gives an artifact what a button never can: ⌘-click, middle-click, open-in-new-tab and copy-link. A redacted artifact has no object left, so its row says redacted rather than offering a link that can only fail.

Hrefs are resolved in Server Components and passed down. A client component never calls surface(): STACKS_SURFACE is deliberately not NEXT_PUBLIC_*, so a client-side read would silently resolve "full" in the browser.

  • Sessions (/[workspace]/sessions) — every connected session with alignment, capture mode, and telemetry; each row opens the session detail with the server-confirmed alignment snapshot and artifact list. The row's project and aligned task are links, and on the detail page so are the alignment's workspace, project and task; each artifact row can be opened there and names the task it landed on.

The cost record, in full

The question the record answers per TASK: who opened it, who participated, who reviewed, and what it cost in tokens and time, priced by the model that actually ran. Four mechanisms carry it, all derived at read time — the only stored spend rows are the ones that are OBSERVATIONS (which interval was open when a receipt arrived is unrecoverable later, so it is persisted when seen).

  • Alignment intervals — every task-aligned stretch of a session is an append-only SessionTaskAlignment row (see the align loop above). The session's taskId is a same-transaction mirror of the open interval.
  • Per-model segments — each heartbeat receipt lands its DELTA into SessionUsageSegment, keyed by (session, open interval or null, model). A field attributes only when both readings reported it and the delta is ≥ 0; a regressed counter attributes nothing — never clamps into a lie. Spend before the first align, or while unaligned, is the first-class UNATTRIBUTED bucket: a rendered state, never an error and never a silent omission.
  • Effective-dated pricingsrc/lib/model-pricing.ts holds per-model entry lists with effectiveFrom; every read prices at the observation instant, and usage predating a model's first entry is UNAVAILABLE rather than priced at a rate that did not govern it. OpenAI/Codex ids are exact keys; every Codex figure is a list-price equivalent (subscription Codex has no billing surface — the PRICING_BASIS line says so wherever cost renders).
  • The unified rolluptask-resource-usage.ts joins both spend planes per task: run plane via AgentJob.taskId → AgentRun, session plane via intervals → segments. On this deployment only the session plane ever populates — agent jobs are a plane the MVP does not carry — so every figure you see here comes from sessions; the join is written surface-agnostically so a full deployment adds the run plane without a second code path. A session that hosts a governed run counts from EXACTLY one plane (the run when it reported, the session otherwise). Every cost carries its basis — runner_reported | list_price | unavailable — and a mixed aggregate itemizes ("$4.10 runner-reported + $5.00 list-price").

Where it renders (the four existing altitudes — deliberately no new page):

  • Task detail → Delivery — the "What it cost" breakdown: cost by basis, attempted vs accepted-outcome (two figures, never netted), per-model lines, work time with its DECLARED basis (measured alignment intervals for sessions, row timestamps for attempts; a mixed total names both), and the coverage sentence: sessions/runs reported · costed · session-spend attributed, plus any spend that stayed session-level.
  • Task detail → Agent work — the Participants section: opened (with "in session …" when the create rode one, "session unknown" for historical MCP creates — never guessed), owner (derived from the latest interval — there is no owner column), operators and labeled agent producers, reviewers with their verdict and latency to decision ("attention time: not measured" is stated, because it isn't). Open review gates render an elapsed-so-far line marked live — a clock, not a final figure.
  • Board cards — the session chip carries the session plane's priced cost (on a full deployment an agent-usage line carries the run plane's too).
  • Work / project pages — the 7-day and per-project rollups carry the third coverage fraction: "X/Y tokens attributed to tasks".

On a full deployment, COST_PER_TASK on the outcomes surface is computed by the outcomes sweep (the one legal converger): the windowed mean of the unified cost over tasks completed in the window, per cohort, with sampleSize = tasks that could actually be priced — a task nothing priced is never averaged in as free. This deployment builds neither the outcomes sweep nor the outcomes surface, so its per-task and per-project figures are the read-time rollups above and nothing is precomputed.

Reconciliation (env-gated, read-only): pnpm reconcile:usage compares windowed platform figures against the Anthropic Usage & Cost Admin API's ledger, per model, mutating zero platform rows. Without ANTHROPIC_ADMIN_KEY the lane no-ops and says "not configured". The report names its scope honestly: the provider ledger covers API-billed traffic only — subscription-plan sessions are listed as the excluded population, and every delta is labeled by cause (TTL conservatism, missing capture, unpriced model) or called unexplained, which is a finding.

One truth per artifact

Decomposition is the orchestrator's actual job, and its record was a copy: the only cross-task handoff in the estate was the same bytes pushed twice — identical checksum, two rows on two tasks. Copies fork the truth the moment one side is revised, and nothing tells the reader which side is current.

  • ArtifactTaskLink lets one artifact row be referenced by N tasks. Provenance stays SINGLE: Artifact.taskId remains the producing task and no link ever rewrites it. The destination card lists the row marked linked, so a reader can always tell the card that produced something from a card that points at it.
  • jentrix push --ref <artifactId> --task <id> references instead of uploading. Same workspace only — a cross-workspace reference is a cross-tenant read dressed up as a convenience, and is refused. Linking an already-linked pair is a no-op, not an error, so an offer can be taken twice.
  • The dedupe path OFFERS a link. When the same bytes arrive for a second task, the ingestion boundary reports it and the CLI prints the --ref command. Reported, never acted on: silently converting a push into a link would change what the operator asked for.
  • A findings push offers to mint an Issues card — creating the task, referencing the artifact onto it, and adding RELATES_TO back to the session's anchor. This is the alignment policy's findings rule turned from a convention into tooling.
  • Every offer is refusable, and none of them can cost you your push. With no BUGS board the CLI says so and skips; it never invents a board or a column. A failed offer is a note on stderr and exit 0, because the artifact is already durable by then.

Finding what was learned

Sixty artifacts in, the only way back to knowledge was scrolling a list of titles. search_tasks covered tasks and comments; artifact bodies — the reports, findings, and the LEARNING kind that exists precisely to change future behavior — were opaque markdown in object storage. Five learnings had been written to be reused and there were zero retrieval paths that would ever resurface them.

  • The index is built inside the ONE ingestion function, in the artifact's own transaction, from the redacted string — the same bytes the checksum is taken over, never the raw input. An index over pre-redaction bytes would be a secret-leak channel with a query interface, which is why it lives there and nowhere a second caller can reach.
  • Not a generated column, unlike Task/Contact/Comment. Those index text that lives in their own row; an artifact's body lives in R2, so there is nothing here to generate from — and copying bodies into Postgres would duplicate megabytes purely to feed an index. Only lexemes are stored.
  • Redaction clears the index in the same transaction that redacts the object. A tombstone that still answers a search discloses what the tombstone was. Archived and redacted rows are excluded by the query as well.
  • Raw TRACE parts are not searchable — transcript spool is private by retention and is not a product artifact.
  • Three surfaces, one predicate: the command palette and the /search page both carry an Artifacts group (hits open the artifact viewer), and search_artifacts (read scope) is the MCP half. Title matches outrank body matches. It is deliberately a new tool rather than a widening of search_tasks, whose result shape is frozen and whose contract is "tasks".
  • Existing artifacts are backfilled by pnpm backfill:artifact-search, which reads the stored (already redacted at ingest) bodies and prints a COVERAGE line rather than a success count: indexed / skipped-no-object / skipped-not-text / unreadable. The unreadable rows are the ones an operator needs to know will never appear in a search.

Embeddings and "related learnings at alignment time" are deliberately deferred: retrieval is the requirement, and recall ranking is a second system.

Review is a recorded verdict

Before this, acceptance was inferred from a column an agent had moved: every "In review" column was empty, finished cards went straight to Done by the session that did the work, and no activity type for human acceptance existed. Review had no noun.

  • Accept / Return live on the task detail panel, with the evidence they are about. Both write an activity row recording who decided, when, and which artifact ids they reviewed; Return additionally requires a reason and is refused without one.
  • The reviewed set is what the reviewer ticked, not "everything on the task at that moment" — the latter would be a claim about a set nobody read. An artifact that does not belong to the task is refused rather than recorded.
  • The verdict is the record; the column move is only its consequence. The row is written first, in the same transaction, so a board whose shape cannot be read loses the convenience of the move and never the audit trail. Accept moves the card to the board's terminal column, Return to the working column before review.
  • Recorded verdicts render where they were recorded. The Review section lists the task's verdict history — who decided, when, over how many artifacts, and the return reason verbatim — newest first, so "was this accepted?" is answered on the card rather than excavated from a collapsed activity feed. On a card sitting in a review column the section leads the Overview tab ("awaiting your verdict"); each offered artifact carries a read link into the viewer, so the evidence is readable from the place it is ticked; and an Accept with nothing ticked says, before it records, that it records a verdict over zero reviewed artifacts. After a move, the action returns the destination column and the open panel patches its own state — the panel and the board never disagree about where the card is.

The queue reads the board, not a marker

/jentrix-end already parks a finished card in "In review", so the state exists with no writer to add. A needsReview marker would need a writer, a clearer, and a converger, and could disagree with the column an operator can see — the two-sources-of-truth failure this codebase rejects everywhere else.

My Work therefore opens with Awaiting your review: tasks parked in an in-review column anywhere in the workspace. It is deliberately not assignee-scoped — work awaiting a verdict is rarely assigned to the person who has to give it. The cost is stated rather than hidden: a workspace whose boards carry no in-review column says so, instead of rendering an empty list that reads as "nothing awaits you". The Home digest leads with the same queue ("N awaiting your review", rows chipped review), and its one-line summary names both facts — the review count and the assigned count — so the landing page can never say "nothing assigned to you" over a populated verdict queue.

The card is the complete review unit

The session-end RUN_SUMMARY now lands on the aligned task, not on the project alone — the report found 10/10 summaries with taskId null while every typed push on the same sessions carried the task. With that, one card answers all four questions without a hop: what happened (the summary), what it cost (the session chips and the spend rollup), what came out (the artifacts), and who did it (the producer chips). Each hop a reviewer must take is a review that eventually does not happen.

Projects that answer "are we closer"

Every MVP project sat in PLANNING since creation with an empty planning spine, so the state told you nothing. Two of the smallest possible changes fix that without inventing a planning apparatus:

  • A project flips PLANNINGACTIVE on the first session aligned into it, server-side on the align submit path. That transition ONLY: a PAUSED, COMPLETED, CANCELLED or ARCHIVED project is never resurrected by an alignment — those are decisions a human made, and an automation that silently reverses one is worse than no automation. The write is conditional on the status it read, so concurrency makes it a no-op rather than a race.
  • /jentrix-end offers a one-line outcome on the project, and offers it — never writing a sentence the operator did not say, and never blocking the close on it.

/jentrix-end also now parks the card in "In review" rather than a terminal column, so the Accept/Return verdict above is the only way work reaches Done. An agent grading its own work as Done is the cheapest habit to break now and the most expensive later.

Decisions stay artifacts. A decision push is an artifact here, not a row in the Decision Register — that register is a full-surface plane, and wiring one push into two write paths would give the same content two sources of truth. Nor is the plane exposed read-only: it would render a permanently empty page that reads as "we have made no decisions". What you get instead is retrieval — a decision artifact is findable by its body from the next session. That is retrieval, not governance, and the distinction is deliberate.

Where an artifact ends up

The MVP's core promise is one sentence: an artifact produced against an aligned checkout lands on that checkout's task, and is visible there, regardless of whether any background process is alive.

  • The typed push resolves its subject either from a session's alignment or from a task named directly — one server-side ingestion function, two addressing modes, identical redaction, dedupe, storage, and audit.
  • A session swept INTERRUPTED still accepts pushes: that status means the host stopped heartbeating, not that the work ended. COMPLETED and CANCELLED mean a human ended the session, so they stay sealed — jentrix push then files the artifact on the marker's task instead and says so.
  • Aligning to "No task — session-level work" is a real choice, and the confirmation card now says what it costs: those artifacts attach to the project only and appear on no task card. Re-aligning moves the anchor, never the artifacts — the confirmation card names how many stay behind and on which task.

What stays available

Everything. Agents keep the full MCP contract in both modes, the CLI is unchanged, and switching STACKS_SURFACE back to unset restores the full operator surface with no data migration — the flag only decides what the web app renders.