diff --git a/CONTEXT.md b/CONTEXT.md index fd3d849..66ac3f7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -142,21 +142,21 @@ A short written answer to "where is this request, and what comes next?". An agen _Avoid_: Summary, status, digest **Release announcement**: -The note that customers would read after the problem is solved. The team writes it as a draft when it captures the problem, long before anything ships. The draft says in the customer's words what changes for them, never how it was built. The request page marks it as a draft until the request is **released**. It is free text on the request, written in the request dialog or through `map_upsert_request`. +The note that customers would read after the problem is solved. A request can hold a draft of it, in the customer's words, long before anything ships. When a **Pitch** is created from the request, the pitch copies the draft into its **Release summary**. After a pitch ships, the request page shows the release summary of that pitch. Until then it shows the request's own draft, marked as a draft. _Avoid_: Changelog, release note (that is the Notion record of a shipped release), press release **Sharp**: -A request with a problem, an **Appetite** and at least one **Outcome**. A request that has fewer is **rough**. Sharpness is derived, never a stored flag, in the same way **Cycle phase** is date-derived ([ADR 0015](docs/adr/0015-cycle-lifecycle-is-date-derived.md)). **Only a sharp request can be bet on**, so nobody bets on a request that never says what would change. +A request with a problem, an **Appetite** and at least one **Outcome**. A request that has fewer is **rough**. Sharpness is derived, never a stored flag, in the same way **Cycle phase** is date-derived ([ADR 0015](docs/adr/0015-cycle-lifecycle-is-date-derived.md)). **Only a sharp request can be pitched**, so no pitch starts from a request that never says what would change. _Avoid_: Framed, ready, validated, approved **Request state**: A request’s position in its life, derived from what it points at and never stored: -`rough` (no appetite) → `candidate` (sharp, no **Pitch** yet) → `in_flight` (a pitch that is not `done`) → `released` (its pitch reached `done`) → `monitoring` (released and not resolved) → `resolved` (a person resolved it). -Monitoring has no end condition. A released request that nobody wakes goes **Dormant** like any other. +`rough` (no appetite or no outcome) → `candidate` (sharp, no **Pitch** yet) → `pitched` (a pitch waits in an upcoming cycle) → `in_flight` (a pitch in a cycle that has started, not `done`) → `released` (its pitch reached `done`) → `monitoring` (released and not resolved) → `resolved` (a person resolved it). +A request with more than one pitch shows the furthest one, except that a pitch still in flight always wins. If the betting table deletes its only pitch, the request goes back to `candidate`. Monitoring has no end condition. A released request that nobody wakes goes **Dormant** like any other. _Avoid_: Status, stage (reserved for a **Pitch**), workflow state **Candidate**: -A sharp request with no **Pitch** yet. A candidate is what the betting table bets on. +A sharp request with no **Pitch** yet. A candidate is ready to be pitched. _Avoid_: Backlog item, proposal, ready **Candidate statement**: @@ -230,7 +230,7 @@ The shaped bet, and the thing a cycle contains. Has a stage, a timebox, scopes, _Avoid_: Shape (that is one part of a pitch), package (that is what a request does with its **Pointers**), project, epic, initiative, ticket **Frame**: -The framing part of a **Pitch**: the full problem, reworked with research, and the outcome the pitch must reach. A **Request** holds a short summary. The frame says it with depth. "Frame Go" means the frame is complete. +The framing part of a **Pitch**: the full problem, reworked with research (`frame_problem`), the outcomes the pitch must reach (`frame_outcomes`), and its appetite (`frame_appetite`). A **Request** holds a short summary. The frame says it with depth. A new pitch copies its frame from its requests: every outcome, the largest appetite, and the problem summaries. After that the pitch owns its frame, and a change to a request does not change it. "Frame Go" is the move from `framing` to `shaping` ([ADR 0032](docs/adr/0032-the-map-holds-requests-and-framing-returns-to-the-pitch.md)). _Avoid_: Frame for the record on the Product Map (that is a **Request**) **Shape**: @@ -238,13 +238,17 @@ The solution part of a **Pitch**, at a high level: its **Scopes**, and visuals o _Avoid_: Shape for the whole bet (that is a **Pitch**), spec, design **Stage**: -A **Pitch**'s current lifecycle phase: `shaping`, `building`, `done`. Can move forward or backward. Cooldown pitches start at `building`. A pitch **auto-advances to `done`** when an update sets the **Needle** to 100% — posting a 100% update is the act of shipping. There is no `framing` stage: framing happens before a pitch exists (see [ADR 0023](docs/adr/0023-framing-leaves-the-shape.md)). `shaping` stays, because shaping work really does happen inside the cycle. -_Avoid_: Status (ambiguous — see Flagged ambiguities), framing (not a stage) +A **Pitch**'s current lifecycle phase: `framing`, `shaping`, `building`, `done`. Can move forward or backward. A new pitch starts at `framing`, in the upcoming cycle, where it waits for the betting table. A cooldown pitch starts at `building`. "Frame Go" is `framing` → `shaping`, and "Shape Go" is `shaping` → `building`. A pitch **auto-advances to `done`** when an update sets the **Needle** to 100% — posting a 100% update is the act of shipping (see [ADR 0032](docs/adr/0032-the-map-holds-requests-and-framing-returns-to-the-pitch.md)). +_Avoid_: Status (ambiguous — see Flagged ambiguities) **Frame as bet**: -The problem text of a **Pitch**'s **Frame**, stored as `frame_problem`, with its outcome stored as free text in `frame_outcome`. The problem is copied from the **Request** when the bet is made. The request on the map keeps changing. This copy does not, so a past cycle always shows what the team committed to (see [ADR 0022](docs/adr/0022-the-frame-is-the-captured-unit.md)). A pitch points home through `request_id`. +The **Frame** of a **Pitch** as it stood when the bet was made. The pitch copies it from its requests when it is created, and the requests on the map keep changing. The copy does not, so a past cycle always shows what the team committed to (see [ADR 0022](docs/adr/0022-the-frame-is-the-captured-unit.md)). A pitch points home through `request_ids`. _Avoid_: Request (that is the living record on the map), requirements, spec, PRD +**Release summary**: +Two or three sentences on a **Pitch**, in the customer's words, that say what changes for them. The full release note lives in Notion, and the pitch links to it through `release_note_url`. +_Avoid_: Changelog, release note (that is the Notion page) + **Timebox**: The fixed time boundary of a pitch. Has start and end dates — but is **optional**: a pitch may have no timebox, in which case its tape is hidden (never "Invalid Date") and it draws no bar on the **Pitch timeline**. Visualized as a tape-measure strip with day ticks and a "today" marker. For the cycle's own span, use **Cycle window** — not "timebox" (see ADR 0010). All of its math — days left, elapsed fill, "Week X of Y" — counts **business days**, not calendar days (see ADR 0013). _Avoid_: Deadline, due date, sprint length @@ -459,7 +463,7 @@ _Avoid_: Scope modal, scope detail dialog, side panel - A **Request** has one **Kind**, one **Type**, zero or one **Request owner**, zero or more **Reports**, and zero or more **Pointers** - A **Request**'s **Type** selects one **Playbook**; the playbook's expected **Pointers** minus the request's own give its **Gap list** - A **Request** has zero or one **Origin request** — the request whose **monitoring** surfaced it -- A **Pitch** points at zero or one **Request**, and keeps a **Frame as bet** taken when the bet was made +- A **Pitch** carries zero or more **Requests**, and keeps a **Frame as bet** taken when it was created - A **Request** can carry several **Pitches** over its life, each in its own **Cycle** - **Mission Control** renders its pitches as a **Pitch timeline** — per-pitch **Timebox** bars grouped by squad on the **Cycle window**'s shared scale; derived, never stored diff --git a/e2e/scope-map.spec.ts b/e2e/scope-map.spec.ts index 245cfff..4dc9e71 100644 --- a/e2e/scope-map.spec.ts +++ b/e2e/scope-map.spec.ts @@ -20,7 +20,7 @@ test.describe('Scope Map view assembly', () => { // Frame columns await expect(page.getByText('Problem')).toBeVisible() - await expect(page.getByText('Outcome')).toBeVisible() + await expect(page.getByText('Outcomes')).toBeVisible() // Scopes section await expect(page.getByRole('heading', { name: 'Scopes' })).toBeVisible() diff --git a/scripts/migrate-frames-to-requests.ts b/scripts/migrate-frames-to-requests.ts index c4ba0ce..ae43175 100644 --- a/scripts/migrate-frames-to-requests.ts +++ b/scripts/migrate-frames-to-requests.ts @@ -10,6 +10,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { Liveblocks, LiveList, LiveObject } from '@liveblocks/node' import { migrateCycleDocument, + migratePitch, migrateProductMapDocument, } from '../src/lib/migrations/frames-to-requests' @@ -56,10 +57,12 @@ async function main() { // @liveblocks/node's LiveList has no toArray(); map() is the way to read it. const pitches: any[] = root.get('pitches')?.map((pitch: any) => pitch) ?? [] for (const pitch of pitches) { - const requestId = pitch.get('frame_id') - if (requestId === undefined) continue - pitch.set('request_id', requestId) - pitch.delete('frame_id') + const before = pitch.toImmutable() as Record + const after = migratePitch(before) + for (const key of Object.keys(before)) if (!(key in after)) pitch.delete(key) + for (const [key, value] of Object.entries(after)) { + if (JSON.stringify(before[key]) !== JSON.stringify(value)) pitch.set(key, value) + } } }) } diff --git a/src/app/[slug]/cycles/[cycleSlug]/mission-control.tsx b/src/app/[slug]/cycles/[cycleSlug]/mission-control.tsx index 4c89a5e..e6a9e65 100644 --- a/src/app/[slug]/cycles/[cycleSlug]/mission-control.tsx +++ b/src/app/[slug]/cycles/[cycleSlug]/mission-control.tsx @@ -1,5 +1,6 @@ 'use client' import { newShapeStage, readStage } from '@/lib/stage-engine' +import { isUpcomingCycle } from '@/lib/product-map-engine' import { ClientSideSuspense } from '@liveblocks/react' import { MissionControlSkeleton } from '@/components/mission-control' @@ -96,10 +97,9 @@ function MissionControlWired({ new LiveObject({ id: nanoid(), title, - stage: newShapeStage(cycle.type), + stage: newShapeStage(cycle.type, isUpcomingCycle(cycle, getTeamToday(new Date()))), needle: null, frame_problem: '', - frame_outcome: '', timebox_start: cycle.start_date, timebox_end: cycle.end_date, emoji: '', diff --git a/src/app/[slug]/product/actions.ts b/src/app/[slug]/product/actions.ts index 16f6e87..64de79a 100644 --- a/src/app/[slug]/product/actions.ts +++ b/src/app/[slug]/product/actions.ts @@ -2,26 +2,27 @@ import { auth } from '@clerk/nextjs/server' import { revalidatePath } from 'next/cache' -import { getProductMapStorage } from '@/lib/mcp/liveblocks-reader' +import { getProductMapStorage, listCycleRooms } from '@/lib/mcp/liveblocks-reader' import { upsertPitch } from '@/lib/mcp/liveblocks-writer' -import { isSharp } from '@/lib/product-map-engine' +import { isSharp, isUpcomingCycle, seedPitchFrame } from '@/lib/product-map-engine' +import { getTeamToday } from '@/lib/team-time' /** - * Bet on a request: create a **Shape** in a cycle that points home to it. + * Pitch a request: create a **Pitch** at `framing` in an upcoming cycle that + * carries it (ADR 0032). The betting table later keeps the pitch or deletes it. * - * The request is re-read here rather than trusted from the client, because this is - * the one rule that has to hold — only a SHARP request can be bet on, and nobody - * bets on half a request. + * The request is re-read here rather than trusted from the client, because this + * is the rule that has to hold: only a SHARP request can be pitched. * - * The shape's problem is a COPY taken now: the **Frame as bet**. The request on - * the Product Map keeps changing and this copy never does, so a past cycle always shows - * what the team committed to (ADR 0022). Nothing here writes back to the Product Map. + * The pitch copies its frame from the request now. After that the pitch owns + * it, so a past cycle always shows what the team committed to. Nothing here + * writes back to the Product Map. */ -export async function betOnRequest(input: { +export async function pitchRequest(input: { requestId: string cycleSlug: string title: string -}): Promise<{ shapeId: string }> { +}): Promise<{ pitchId: string }> { const { userId, orgId, orgSlug } = await auth() if (!userId) throw new Error('Not authenticated') if (!/^[a-zA-Z0-9_-]+$/.test(input.cycleSlug)) throw new Error('Invalid cycle') @@ -32,19 +33,24 @@ export async function betOnRequest(input: { if (!request) throw new Error(`Request not found: "${input.requestId}"`) if (!isSharp(request)) { throw new Error( - 'A rough request cannot become a shape. Give it a problem and an appetite first.' + 'A rough request cannot be pitched. Give it a problem, an appetite and an outcome first.' ) } - // A new shape starts at `shaping`: framing already happened, on the Product Map - // (ADR 0023). + // listCycleRooms, not readCycleWindows: a Liveblocks failure must surface as + // itself, not as "there is no upcoming cycle". + const cycle = (await listCycleRooms(orgPrefix)).find((c) => c.slug === input.cycleSlug) + if (!cycle || !isUpcomingCycle(cycle, getTeamToday(new Date()))) { + throw new Error('Pitch into an upcoming cycle. Create the next cycle first if there is none.') + } + const result = await upsertPitch(`${orgPrefix}:cycle:${input.cycleSlug}`, { title: input.title.trim() || request.problem, - stage: 'shaping', - frame_problem: request.problem, - request_id: request.id, + stage: 'framing', + ...seedPitchFrame([request]), + request_ids: [request.id], }) revalidatePath(`/${orgSlug ?? 'me'}/product`) - return { shapeId: result.id } + return { pitchId: result.id } } diff --git a/src/app/[slug]/product/page.test.ts b/src/app/[slug]/product/page.test.ts index 963dca7..0f3c9fa 100644 --- a/src/app/[slug]/product/page.test.ts +++ b/src/app/[slug]/product/page.test.ts @@ -87,7 +87,7 @@ describe('ProductMapPage', () => { ]) vi.mocked(getCycleStorage).mockResolvedValueOnce({ pitches: [ - { id: 'p1', title: 'Fix imports', stage: 'building', request_id: 'f1' }, + { id: 'p1', title: 'Fix imports', stage: 'building', request_ids: ['f1'] }, { id: 'p2', title: 'No request here', stage: 'building' }, ], } as never) diff --git a/src/app/[slug]/product/product-map.tsx b/src/app/[slug]/product/product-map.tsx index 1b99048..92396e6 100644 --- a/src/app/[slug]/product/product-map.tsx +++ b/src/app/[slug]/product/product-map.tsx @@ -36,6 +36,7 @@ import { storeRelated, isMemberId, renderProductMap, + isUpcomingCycle, type CycleWindow, type RequestState, type LinkedShape, @@ -47,7 +48,7 @@ import { } from '@/lib/product-map-engine' import { getTeamToday } from '@/lib/team-time' import type { OrganizationUser } from '@/lib/users' -import { betOnRequest } from './actions' +import { pitchRequest } from './actions' import { KIND_LABELS, STATE_LABELS, TYPE_LABELS } from '@/components/product-map/labels' import { KIND_ICONS, TYPE_ICONS } from '@/components/product-map/request-icons' import { ExternalLink, Pencil, Plus, Trash2, X, type LucideIcon } from 'lucide-react' @@ -924,13 +925,15 @@ function Resolve({ pin, onClose }: { pin: RenderedPin; onClose: () => void }) { } /** - * Every shape that attacked this request, with its cycle, plus the way to bet on + * Every pitch that carried this request, with its cycle, plus the way to pitch * it. A request outlives the work done against it, so the same problem can be - * attacked again years later and both bets stay visible. The list is read from - * the cycle rooms and never stored on the request (ADR 0022). + * attacked again years later and both pitches stay visible. The list is read + * from the cycle rooms and never stored on the request (ADR 0022). */ function Shapes({ pin }: { pin: RenderedPin }) { - const cycles = useContext(CyclesContext) + // A new pitch waits for the betting table in a cycle that has not started. + const today = getTeamToday(new Date()) + const cycles = useContext(CyclesContext).filter((cycle) => isUpcomingCycle(cycle, today)) const [cycleSlug, setCycleSlug] = useState('') const [error, setError] = useState('') const [betting, setBetting] = useState(false) @@ -939,7 +942,7 @@ function Shapes({ pin }: { pin: RenderedPin }) { setBetting(true) setError('') try { - await betOnRequest({ requestId: pin.requestId, cycleSlug, title: pin.problem }) + await pitchRequest({ requestId: pin.requestId, cycleSlug, title: pin.problem }) } catch (err) { setError((err as Error).message) } finally { @@ -949,7 +952,7 @@ function Shapes({ pin }: { pin: RenderedPin }) { return (
- + - {/* Only a sharp request can be bet on, so a rough one is offered no bet at - all. The action re-checks server-side: this is the courtesy, not the - rule. */} + {/* Only a sharp request can be pitched, so a rough one is offered no pitch + at all. The action re-checks server-side: this is the courtesy, not + the rule. */} {pin.sharp && cycles.length > 0 && (