storybook: stop predicting the project in Jev task routing

Remove the project choice question and the project list from the Jev
request, along with its simulation, confidence row, and stabilizer rule.
Project stays a plain manual picker. Project leads remain in agent
descriptions because they help Jev choose the owner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWn6etZjfGz6p8NWYkCQve
This commit is contained in:
Claude committed 2026-09-24 22:09:57 +00:00
1 parent 35109298e5
commit e38fb7be45
4 files changed
+23 -53

No files matched your search

@@ -13,7 +13,6 @@ import {
JEV_MIN_PROMPT_LENGTH,
JEV_MODEL,
JEV_NAME,
JEV_NO_PROJECT,
JEV_PROJECTS,
agentContextCoverage,
stabilizeRouting,
@@ -26,7 +25,7 @@ import {
export type JevTaskComposerProps = {
/**
* Both flows update mode, owner, and project live while you type. The title is
* Both flows update mode and owner live while you type. The title is
* drafted once, when the task is started (Start task or Enter), like Claude
* Code naming a session after the first message.
* `suggest-first`: review the live suggestions, then create.
@@ -173,7 +172,6 @@ export function JevTaskComposer({
const changed: Partial<Record<JevField, boolean>> = previous ? {
workMode: previous.workMode !== next.workMode,
assignee: previous.assigneeId !== next.assigneeId,
project: previous.projectId !== next.projectId,
} : {};
routingRef.current = next;
setRouting(next);
@@ -241,7 +239,8 @@ export function JevTaskComposer({
const effective = useMemo(() => ({
title: overrides.title ?? suggestedTitle ?? "",
assignee: overrides.assignee ?? routing?.assigneeId ?? null,
project: overrides.project !== undefined ? overrides.project : routing?.projectId ?? null,
// Project is not predicted; it stays a manual choice.
project: overrides.project ?? null,
workMode: overrides.workMode ?? routing?.workMode ?? "standard",
}), [overrides, routing, suggestedTitle]);
@@ -517,7 +516,7 @@ function RoutingPanel({
return `${JEV_NAME} wasn't sure who should own this (${percent(routing!.confidence.assignee)}), so it goes to ${fallbackOwner.name}, ${why}.`;
}
if (status === "idle" && promptLength === 0 && !created) {
return `As you type, ${JEV_NAME} picks the mode, owner, and project. The title is written when you ${flow === "instant" ? "start" : "create"} the task.`;
return `As you type, ${JEV_NAME} picks the mode and owner. The title is written when you ${flow === "instant" ? "start" : "create"} the task.`;
}
if (status === "idle" && promptLength > 0 && promptLength < JEV_MIN_PROMPT_LENGTH) return "Keep going. Suggestions start once there's a bit more to go on.";
return null;
@@ -596,10 +595,8 @@ function RoutingPanel({
<PropertyChip
label="Project"
valueKey={effective.project ?? "none"}
flash={flash.project}
loading={loading}
suggested={fromJev("project") && effective.project !== null}
onReset={resetFor("project")}
loading={false}
suggested={false}
display={<><FolderKanban aria-hidden className="size-3.5" />{JEV_PROJECTS.find((project) => project.id === effective.project)?.name ?? "No project"}</>}
>
{(close) => [
@@ -607,7 +604,6 @@ function RoutingPanel({
<MenuItem
key={project.id}
selected={project.id === effective.project}
probability={probabilities?.project[project.id]}
onClick={() => { setField("project", project.id); close(); }}
>
{project.name}
@@ -616,7 +612,6 @@ function RoutingPanel({
<MenuItem
key="none"
selected={effective.project === null}
probability={probabilities?.project[JEV_NO_PROJECT]}
onClick={() => { setField("project", null); close(); }}
>
No project
@@ -652,7 +647,7 @@ function RoutingPanel({
);
}
const FIELD_LABELS: Record<JevField, string> = { workMode: "Mode", assignee: "Assignee", project: "Project" };
const FIELD_LABELS: Record<JevField, string> = { workMode: "Mode", assignee: "Assignee" };
function choiceLabel(field: JevField, routing: JevRouting): string {
if (field === "assignee") {
@@ -661,12 +656,11 @@ function choiceLabel(field: JevField, routing: JevRouting): string {
? `${name(routing.assigneeId)} (fallback; ${JEV_NAME} leaned ${name(routing.jevAssigneeId)})`
: name(routing.assigneeId);
}
if (field === "project") return JEV_PROJECTS.find((project) => project.id === routing.projectId)?.name ?? "No project";
return workModeMetaFor(routing.workMode).label;
}
function ConfidenceDetails({ routing, prompt, agents, liveStats }: { routing: JevRouting; prompt: string; agents: typeof JEV_AGENTS; liveStats: LiveStats }) {
const fields: JevField[] = ["workMode", "assignee", "project"];
const fields: JevField[] = ["workMode", "assignee"];
return (
<div className="mt-2 flex flex-col gap-2 pl-4">
<dl className="grid grid-cols-[auto_1fr_auto] items-center gap-x-3 gap-y-1.5">
@@ -17,8 +17,8 @@ Two models do the work, because they are good at different things:
- **[Jev](https://openrouter.ai/docs/guides/community/jev)** (`typesafe/jev-1.13`,
TypeSafe's decision model on OpenRouter) answers typed `choice` questions for
the task's **mode**, which is its type (Auto, Plan, or Ask), plus its
**assignee** and **project**. It returns a choice, a
the task's **mode**, which is its type (Auto, Plan, or Ask), and its
**assignee**. It returns a choice, a
confidence, and a probability for every option. It is fast and cheap: you pay
for input tokens only, and output tokens are free. It does not generate text or
explain its answers.
@@ -27,7 +27,7 @@ Two models do the work, because they are good at different things:
## What the prototype shows
- **Live routing while you type.** Mode, assignee, and project re-route about
- **Live routing while you type.** Mode and assignee re-route about
250 ms after each pause in typing, and at least every 800 ms during continuous
typing. Jev is fast and cheap enough for this: a call costs about $0.00004.
- **Steady suggestions.** A chip only switches when Jev's new pick leads the
@@ -41,6 +41,9 @@ Two models do the work, because they are good at different things:
**Enter**, like Claude Code naming a session after the first message. It types
into the header breadcrumb and you can click it to edit. Shift+Enter adds a new
line.
- **Project is not predicted.** It stays a plain manual picker (default "No
project"). Project leads still appear in each agent's description, because
they help Jev pick the owner.
- **Mode is the task's type** and uses the shipped `IssueWorkMode` values and
labels:
- **Auto** (`standard`): the agent does the work.
@@ -74,8 +77,8 @@ Two models do the work, because they are good at different things:
## What's real and what's simulated
`jev-classifier.ts` builds the real Decisions API request body
(`buildJevDecisionRequest`): the prompt, the company's agents and projects in
`state`, and three `choice` questions. The request and response types follow the
(`buildJevDecisionRequest`): the prompt and the company's agents in `state`,
and two `choice` questions. The request and response types follow the
OpenRouter docs.
Each agent's option in the assignee question is described by
@@ -106,7 +109,7 @@ too (`draftTitle`). No API is called and no task is persisted.
1. Add a company-scoped server route, for example
`POST /api/companies/:companyId/issues/suggest`. It builds the request from
the company's active agents and projects, leaving out paused agents and agents
the company's active agents and their context, leaving out paused agents and agents
over budget. It calls `POST https://openrouter.ai/api/alpha/decisions` with the
instance's OpenRouter key, and a small chat model for the title, in parallel.
Keep the key on the server.
@@ -8,8 +8,7 @@ import type { IssueWorkMode } from "@paperclipai/shared";
* does not generate text or explain itself. So routing is split in two:
*
* - Jev (`POST /api/alpha/decisions`, model `typesafe/jev-1.13`) answers `choice`
* questions for the task's mode (its type: Auto, Plan, or Ask), assignee, and
* project.
* questions for the task's mode (its type: Auto, Plan, or Ask) and assignee.
* - A small text model drafts the title, the way Claude Code names a session.
*
* `buildJevDecisionRequest` produces the real request body. The response is
@@ -191,7 +190,6 @@ export function agentContextCoverage(agents: JevAgent[], projects: JevProject[]
};
}
export const JEV_NO_PROJECT = "none";
// ---------------------------------------------------------------------------
// Request builder: this is what a server would send to OpenRouter.
@@ -205,7 +203,6 @@ export function buildJevDecisionRequest(prompt: string, agents: JevAgent[] = JEV
task_prompt: prompt,
// Each agent's full description lives in the assignee criteria, so it is sent once.
agents: available.map(({ id, name, title, reportsTo }) => ({ id, name, title, reports_to: reportsTo ?? "board" })),
projects: JEV_PROJECTS.map(({ id, name, description, leadAgentId }) => ({ id, name, description, lead_agent_id: leadAgentId })),
},
questions: {
work_mode: {
@@ -222,14 +219,6 @@ export function buildJevDecisionRequest(prompt: string, agents: JevAgent[] = JEV
instructions: "Which agent should own this task?",
criteria: Object.fromEntries(available.map((agent) => [agent.id, describeAgentForJev(agent)])),
},
project: {
type: "choice",
instructions: "Which project does this task belong to?",
criteria: {
...Object.fromEntries(JEV_PROJECTS.map((project) => [project.id, project.description])),
[JEV_NO_PROJECT]: "None of the projects fit.",
},
},
},
};
}
@@ -259,12 +248,6 @@ const AREA_OWNER: Record<WorkArea, string> = {
ops: "agent-darnold",
};
const PROJECT_KEYWORDS: Record<string, string[]> = {
"project-board-ui": ["ui", "board", "dashboard", "page", "button", "dialog", "sidebar", "inbox", "screen", "mobile", "login", "storybook"],
"project-agent-runtime": ["agent", "runtime", "heartbeat", "adapter", "wake", "codex", "claude", "runner", "session"],
"project-budget-guardrails": ["budget", "spend", "cost", "billing", "limit", "invoice", "quota"],
};
function score(text: string, keywords: string[]): number {
return keywords.reduce((total, keyword) => total + (text.includes(keyword) ? 1 : 0), 0);
}
@@ -310,11 +293,6 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe
const assigneeTotal = Object.values(assigneeProbabilities).reduce((sum, value) => sum + value, 0);
for (const id of Object.keys(assigneeProbabilities)) assigneeProbabilities[id]! /= assigneeTotal;
const projectScores: Record<string, number> = {
...Object.fromEntries(Object.entries(PROJECT_KEYWORDS).map(([id, keywords]) => [id, score(text, keywords)])),
[JEV_NO_PROJECT]: 0.5,
};
const isQuestion = /\?\s*$/.test(prompt.trim()) || (/^(how|why|what|which|should|is|are|can|does)\b/.test(text) && !/^(can|could|would) you\b/.test(text));
const isLarge = /\b(plan|roadmap|migrate|migration|redesign|rewrite|overhaul|multi-step|phases?)\b/.test(text);
const isInvestigation = /^(investigate|research|find out|figure out|look into|compare|evaluate)\b/.test(text);
@@ -327,7 +305,6 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe
answers: {
work_mode: choiceAnswer(softmax(modeScores)),
assignee: choiceAnswer(assigneeProbabilities),
project: choiceAnswer(softmax(projectScores, 2.4)),
},
// Output tokens are free on Jev; $0.042 per million input tokens.
usage: { input_tokens: inputTokens, output_tokens: 0, cost: (inputTokens * 0.042) / 1_000_000 },
@@ -337,7 +314,7 @@ export function simulateJevDecisions(request: JevDecisionRequest): JevDecisionRe
// ---------------------------------------------------------------------------
// What the composer consumes
export type JevField = "workMode" | "assignee" | "project";
export type JevField = "workMode" | "assignee";
/**
* Who gets a task when Jev isn't confident about the owner: the org's first
@@ -356,7 +333,6 @@ export type JevRouting = {
assigneeSource: "jev" | "fallback";
/** Jev's own top pick, kept even when the fallback is used. */
jevAssigneeId: string;
projectId: string | null;
confidence: Record<JevField, number>;
probabilities: Record<JevField, Record<string, number>>;
/** Jev's likely owners other than the one assigned, offered when Jev is unsure. */
@@ -365,7 +341,7 @@ export type JevRouting = {
};
export function routingFromJev(response: JevDecisionResponse, agents: JevAgent[] = JEV_AGENTS): JevRouting {
const { work_mode: mode, assignee, project } = response.answers as Record<string, JevChoiceAnswer>;
const { work_mode: mode, assignee } = response.answers as Record<string, JevChoiceAnswer>;
const fallbackId = assignee!.confidence < JEV_CONFIDENCE_THRESHOLD ? fallbackAssigneeId(agents) : null;
const assigneeId = fallbackId ?? assignee!.choice;
const alternateAssigneeIds = Object.entries(assignee!.probabilities)
@@ -377,9 +353,8 @@ export function routingFromJev(response: JevDecisionResponse, agents: JevAgent[]
assigneeId,
assigneeSource: fallbackId ? "fallback" : "jev",
jevAssigneeId: assignee!.choice,
projectId: project!.choice === JEV_NO_PROJECT ? null : project!.choice,
confidence: { workMode: mode!.confidence, assignee: assignee!.confidence, project: project!.confidence },
probabilities: { workMode: mode!.probabilities, assignee: assignee!.probabilities, project: project!.probabilities },
confidence: { workMode: mode!.confidence, assignee: assignee!.confidence },
probabilities: { workMode: mode!.probabilities, assignee: assignee!.probabilities },
alternateAssigneeIds,
usage: response.usage,
};
@@ -398,8 +373,6 @@ export function stabilizeRouting(previous: JevRouting | null, next: JevRouting):
(next.probabilities[field][nextChoice] ?? 0) - (next.probabilities[field][previousChoice] ?? 0) < JEV_SWITCH_MARGIN;
const result: JevRouting = { ...next };
if (keepPrevious("workMode", previous.workMode, next.workMode)) result.workMode = previous.workMode;
const projectKey = (id: string | null) => id ?? JEV_NO_PROJECT;
if (keepPrevious("project", projectKey(previous.projectId), projectKey(next.projectId))) result.projectId = previous.projectId;
if (previous.assigneeSource === "jev" && next.assigneeSource === "jev" && keepPrevious("assignee", previous.assigneeId, next.assigneeId)) {
result.assigneeId = previous.assigneeId;
result.jevAssigneeId = previous.assigneeId;
@@ -21,7 +21,7 @@ const meta = {
description: {
component:
"Prototype: prompt-first task creation. You write what you need. Jev (`typesafe/jev-1.13`, TypeSafe's decision model on OpenRouter) answers " +
"`choice` questions for the task's mode, which is its type (Auto, Plan, or Ask), plus its assignee and project, each with a confidence and per-option probabilities. Mode, owner, and project update live as you type. Jev doesn't generate text, so a separate small text model drafts the title once, when the task is started with Start task or Enter, like Claude Code naming a session. " +
"`choice` questions for the task's mode, which is its type (Auto, Plan, or Ask), plus its assignee, each with a confidence and per-option probabilities. Mode and owner update live as you type; project is a manual choice. Jev doesn't generate text, so a separate small text model drafts the title once, when the task is started with Start task or Enter, like Claude Code naming a session. " +
"Suggested values carry a sparkle. Anything you change is yours and is never overwritten; the undo icon restores the suggestion. " +
"Below the confidence threshold the task goes to the org's first active agent that reports to the board (else its first active agent), and Jev's best guesses appear as one-click buttons. **Instant** stories create first; the title and routing arrive a moment later. " +
"Jev's answers are simulated locally in the documented response shape (`prototypes/jev-task-creation/jev-classifier.ts`); **How sure Jev is → Request sent to Jev** shows the real request body. No API is called and no task is persisted.",