Fix twenty findings from the Motion review
CI / verify (push) Successful in 4m47s
CI / publish (push) Failing after 3s

Each was raised by a reviewer and then survived an independent attempt to
refute it. The four that mattered most:

- A third of the starter library was invisible. Three templates authored
  `fields` shapes no renderer read — decisions, blockingSet, checks,
  steps and the rest — so about forty records rendered as no DOM at all,
  in the library and again on the engagement that instantiated them.
  Nothing failed: a renderer returns null for a key set it does not
  recognise, and a header-plus-body page looks like a template written
  that way. FieldsView now reads every key the seeds carry.
- "Add a framework" opened a picker that could never match, because the
  dialog was seeded with both the forced kind and the deal's stage, and
  qualification serves only the qualification stage. The stage is now
  dropped when MOTION_KIND_STAGES says the pair is incoherent.
- Piggy reported the promotion count as an exact figure capped at 8,
  against a tile showing the true count beside it. It is now counted in
  SQL, and all three motion tools carry a ResultScope whose denominator
  is shared lineages — never rows, never private drafts.
- No Motion test went through createApp, so the whole feature could be
  unmounted with a green suite. That is the AGENTS.md §5 trap that
  already cost this project read-guards.ts and learn.ts.

Also: both sides of the instantiate/edit race now lock, so a template
cannot be rewritten under an artefact that has copied it; concurrent
engagement opens queue on the deal row and get the 409 the handler
already promised rather than a 500; latestScore uses DISTINCT ON instead
of losing engagements past a 200-row cap; the migration adds the
scored_by_user_id foreign key the schema declares; and the demo clear
refunds usage_count for engagements it reaches by cascade, which
otherwise left starter templates permanently un-editable.

Verified on a fresh database: 16 migrations apply and re-apply as a
no-op, both seeds idempotent, usage_count back to zero after --clear.
564 unit tests pass. Every Motion route measures zero horizontal
overflow at 393 and 1440 in both themes, and all twelve seeded field
trees are asserted onto the screen by scripts/motion-fields-check.mjs.

One thing left open deliberately: the shipped qualification scorecard's
five bands and MOTION_BANDS' four are calibrated differently. The
framework's table is now titled as its own guidance rather than the
product's verdict, which removes the contradiction on screen. Making the
framework's calibration authoritative over the persisted band column is
a product decision nobody has made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 19:08:55 -07:00
parent 376ef3d597
commit 15c72ade1c
16 changed files with 1281 additions and 66 deletions
+17 -10
View File
@@ -9,9 +9,9 @@
*/
import { Fragment } from 'react';
import { X } from 'lucide-react';
import { Link, useMatch, useResolvedPath } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useIdentity } from '@/lib/identity';
import { NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { activeNavItem, NAV_GROUP_HEADING, NAV_GROUPS, visibleNav, type NavItem } from '@/lib/nav';
import { AccountSwitcher } from './AccountSwitcher';
import { Button, Label } from './ui';
import {
@@ -33,6 +33,16 @@ export function AppSidebar() {
const identity = useIdentity();
const items = visibleNav(identity);
const { isMobile, setOpenMobile } = useSidebar();
const { pathname } = useLocation();
/*
* One winner for the whole rail, decided here rather than by each row asking
* the router about itself. Motion is the first group whose destinations nest
* — `/motion` is a prefix of `/motion/library` — and a per-row match lit both
* of those at once, so the sidebar and the header disagreed about which page
* you were on. `activeNavItem` is the same longest-match helper the header
* titles with, which is what keeps them from ever disagreeing again.
*/
const current = activeNavItem(items, pathname);
return (
<Sidebar collapsible="icon">
@@ -75,7 +85,7 @@ export function AppSidebar() {
<SidebarGroupContent>
<SidebarMenu>
{groupItems.map((item) => (
<NavItemRow key={item.to} item={item} />
<NavItemRow key={item.to} item={item} isActive={current?.to === item.to} />
))}
</SidebarMenu>
</SidebarGroupContent>
@@ -103,14 +113,11 @@ export function AppSidebar() {
);
}
function NavItemRow({ item }: { item: NavItem }) {
// `asChild` renders the row *as* the link rather than wrapping one, so there is
// a single focusable element per row. Active state arrives as a prop because it
// is a question about the whole table — see the caller.
function NavItemRow({ item, isActive }: { item: NavItem; isActive: boolean }) {
const { setOpenMobile, isMobile } = useSidebar();
// `asChild` renders the row *as* the link rather than wrapping one, so there
// is a single focusable element per row. Active state is asked of the router
// instead of compared against a pathname, so `/demand/abc` still lights
// Demand and `/` does not light everything.
const resolved = useResolvedPath(item.to);
const isActive = useMatch({ path: resolved.pathname, end: item.to === '/' }) !== null;
return (
<SidebarMenuItem>
+343 -8
View File
@@ -38,10 +38,10 @@ export function FieldsView({
const record = asRecord(fields);
if (!record) return null;
const body = renderKind(kind, record);
if (!body) return null;
return <div className={cn('min-w-0 space-y-5', className)}>{body}</div>;
// Emptiness is decided inside each kind's renderer, not here: `renderKind`
// hands back an element, and an element is truthy however little it draws.
// A guard at this level could only ever read as one and never fire.
return <div className={cn('min-w-0 space-y-5', className)}>{renderKind(kind, record)}</div>;
}
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
@@ -71,7 +71,11 @@ function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNod
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
if (sections.length === 0) return null;
const decisions = recordList(fields.decisions);
const blockingSet = recordList(fields.blockingSet);
if (sections.length === 0 && decisions.length === 0 && blockingSet.length === 0) return null;
const blockingCount = decisions.filter((decision) => decision.blocking === true).length;
return (
<>
@@ -101,6 +105,84 @@ function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
</Section>
);
})}
{decisions.length === 0 ? null : (
<Section
title="Decisions"
// A decision brief is read to find what is still open, and a blocking
// row is a different object from the rest: it ends the deal rather
// than delaying it, so the count leads.
aside={blockingCount > 0 ? `${blockingCount} of ${decisions.length} blocking` : undefined}
>
<div className="min-w-0 space-y-3">
{decisions.map((decision, index) => {
const question = text(decision.question);
if (!question) return null;
const area = text(decision.area);
const id = text(decision.id);
const options = recordList(decision.options);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{area ? <Badge tone="neutral">{area}</Badge> : null}
{decision.blocking === true ? <Badge tone="danger">Blocking</Badge> : null}
{/* The blocking set below names decisions by id, so the id
is content here rather than a React key. */}
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
</div>
<p className="mt-2 min-w-0 break-words font-medium leading-6">{question}</p>
<Note label="Answered by" value={text(decision.answeredBy)} />
{options.length === 0 ? null : (
<ul className="mt-3 min-w-0 space-y-3 border-t border-border pt-3">
{options.map((option, optionIndex) => {
const choice = text(option.option);
if (!choice) return null;
const verdict = text(option.verdict);
const escalatesTo = text(option.escalatesTo);
return (
<li key={optionIndex} className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{verdict ? <Badge tone={verdictTone(verdict)}>{verdict}</Badge> : null}
{/* Who answers follows the option chosen, not the
topic: a lead who settles a weights question
off the cuff has priced nothing. */}
{escalatesTo ? (
<span className="whitespace-nowrap text-xs text-muted">
escalates to {escalatesTo}
</span>
) : null}
</div>
<p className="mt-1.5 min-w-0 break-words text-sm leading-6">{choice}</p>
<Note label="Costs" value={text(option.costs)} />
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
</Section>
)}
{blockingSet.length === 0 ? null : (
<Section title="The blocking set" icon={<Skull className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{blockingSet.map((entry, index) => {
const item = text(entry.item);
if (!item) return null;
return (
<li key={index} className="min-w-0">
<p className="min-w-0 break-words font-medium leading-6">{item}</p>
<Pills label="Decisions" items={textList(entry.decisionIds)} />
<Note label="Why it kills the deal" value={text(entry.whyItKills)} tone="warning" />
</li>
);
})}
</ul>
</Section>
)}
</>
);
}
@@ -147,7 +229,14 @@ function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
)}
{bands.length === 0 ? null : (
<Section title="What each score means">
// Not "what each score means": these bands are the framework author's
// own calibration on their own scale, and `MOTION_BANDS` is the
// product's — four bands in basis points, shown as a badge on the same
// page. The shipped scorecard's five bands genuinely disagree with it
// at 7500 (Strategic, against "do not start compute"), so a heading
// that read as the product's verdict put two opposite instructions
// about one number on one screen.
<Section title="What this framework says to do at each score">
<ul className="min-w-0 space-y-2">
{bands.map((band, index) => {
const label = text(band.label);
@@ -308,7 +397,30 @@ function PricingFields({ fields }: { fields: Record<string, unknown> }) {
const inputs = recordList(fields.inputs);
const packages = recordList(fields.packages);
const tradeables = recordList(fields.tradeables);
if (inputs.length === 0 && packages.length === 0 && tradeables.length === 0) return null;
const budgetSources = recordList(fields.budgetSources);
const forecast = asRecord(fields.computeForecast);
const questionnaire = recordList(fields.questionnaireMap);
const justification = asRecord(fields.soleSourceJustification);
const steps = recordList(fields.steps);
const championHomework = recordList(fields.championHomework);
if (
inputs.length === 0 &&
packages.length === 0 &&
tradeables.length === 0 &&
budgetSources.length === 0 &&
!forecast &&
questionnaire.length === 0 &&
!justification &&
steps.length === 0 &&
championHomework.length === 0
) {
return null;
}
// Serial is the number the champion's own calendar produces if nobody runs
// anything beside anything else, and it is what makes the `parallelWith`
// column below worth reading.
const serialDays = steps.reduce((total, step) => total + (number(step.typicalDays) ?? 0), 0);
return (
<>
@@ -356,6 +468,130 @@ function PricingFields({ fields }: { fields: Record<string, unknown> }) {
/>
</Section>
)}
{budgetSources.length === 0 ? null : (
<Section title="Where the money comes from">
<div className="min-w-0 space-y-3">
{budgetSources.map((entry, index) => {
const source = text(entry.source);
if (!source) return null;
const days = number(entry.typicalDays);
return (
<div key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{source}</span>
{entry.fastest === true ? <Badge tone="positive">Fastest</Badge> : null}
{days === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
)}
</div>
<Note label="Approvers" value={text(entry.approvers)} />
{/* Speed and durability are different questions and the
fastest source is routinely the least durable one, so
neither is shown without the other. */}
<Note label="Durability" value={text(entry.durability)} />
<Note label="Durability test" value={text(entry.durabilityTest)} />
<Note label="Watch for" value={text(entry.watchFor)} tone="warning" />
</div>
);
})}
</div>
</Section>
)}
{forecast ? (
<Section title="Compute forecast">
<Note label="Audience" value={text(forecast.audience)} />
<ScrollTable
head={['Line', 'Low', 'Expected', 'High']}
rows={recordList(forecast.lines).map((line) => [
text(line.line),
text(line.low),
text(line.expected),
text(line.high),
])}
/>
<Note label="Ceiling rule" value={text(forecast.ceilingRule)} />
{/* Cost is charged against the full commitment, not the hours that
sold — AGENTS.md §4 — so the rule travels with the forecast that
tempts a reader to model it the other way. */}
<Note label="Commitment rule" value={text(forecast.commitmentRule)} />
</Section>
) : null}
{questionnaire.length === 0 ? null : (
<Section title="AI vendor questionnaire">
<ScrollTable
head={['Topic', 'Answered from']}
rows={questionnaire.map((entry) => [text(entry.topic), text(entry.source)])}
/>
</Section>
)}
{justification ? (
<Section title="Sole-source justification">
<Note label="When it is needed" value={text(justification.whenNeeded)} />
{recordList(justification.paragraphs).map((paragraph, index) => {
const heading = text(paragraph.heading);
if (!heading) return null;
const draft = text(paragraph.draft);
return (
<div key={index} className="mt-3 min-w-0">
<p className="min-w-0 break-words text-sm font-medium leading-6">{heading}</p>
{draft ? (
// The champion pastes this into their own requisition, so
// the whitespace the author wrote is part of the paragraph.
<p className="mt-1.5 min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{draft}
</p>
) : null}
</div>
);
})}
<Note label="Note" value={text(justification.note)} />
</Section>
) : null}
{steps.length === 0 ? null : (
<Section
title="The critical path"
aside={serialDays > 0 ? `${serialDays} days end to end, serially` : undefined}
>
<ol className="min-w-0 space-y-3">
{steps.map((entry, index) => {
const step = text(entry.step);
if (!step) return null;
const days = number(entry.typicalDays);
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="min-w-0 break-words font-medium leading-6">{step}</span>
{days === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">~{days} days</span>
)}
</div>
<Note label="Produces" value={text(entry.produces)} />
<Note label="Runs beside" value={text(entry.parallelWith)} />
{/* Procurement never says no, it goes quiet, and the stated
reason for the silence is almost never the real one — so
the symptom and the move are the part that gets read. */}
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
<Note label="Unstick move" value={text(entry.unstickMove)} />
</li>
);
})}
</ol>
</Section>
)}
{championHomework.length === 0 ? null : (
<Section title="The champion's homework">
<ScrollTable
head={['What they do', 'Why']}
rows={championHomework.map((entry) => [text(entry.task), text(entry.why)])}
/>
</Section>
)}
</>
);
}
@@ -473,7 +709,21 @@ function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
const stages = recordList(fields.stages);
const research = asRecord(fields.researchInterface);
const promotion = recordList(fields.promotion);
if (stages.length === 0 && !research && promotion.length === 0) return null;
const checks = recordList(fields.checks);
const fallbacks = recordList(fields.fallbacks);
const firstThirtyDays = recordList(fields.firstThirtyDays);
if (
stages.length === 0 &&
!research &&
promotion.length === 0 &&
checks.length === 0 &&
fallbacks.length === 0 &&
firstThirtyDays.length === 0
) {
return null;
}
const gateCount = checks.filter((check) => check.gate === true).length;
return (
<>
@@ -504,6 +754,79 @@ function PlaybookFields({ fields }: { fields: Record<string, unknown> }) {
</Section>
) : null}
{checks.length === 0 ? null : (
<Section
title="Readiness checks"
// A checklist nobody can fail is a document. The gates are what make
// this one a gate, so their share is stated before the list.
aside={gateCount > 0 ? `${gateCount} of ${checks.length} are gates` : undefined}
>
<ul className="min-w-0 space-y-3">
{checks.map((entry, index) => {
const check = text(entry.check);
if (!check) return null;
const area = text(entry.area);
const id = text(entry.id);
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{area ? <Badge tone="neutral">{area}</Badge> : null}
{/* A gate stops the traffic ramp rather than being work
somebody catches up on, which is the same reason a
POC's kill gate is the one property that earns colour. */}
{entry.gate === true ? <Badge tone="danger">Gate</Badge> : null}
{/* The fallbacks and the first thirty days name checks by
id, so the id is content rather than a React key. */}
{id ? <span className="whitespace-nowrap text-xs text-muted">{id}</span> : null}
</div>
<p className="mt-2 min-w-0 break-words leading-6">{check}</p>
<Note label="Evidence" value={text(entry.evidence)} />
<Note label="Owner" value={text(entry.owner)} />
</li>
);
})}
</ul>
</Section>
)}
{fallbacks.length === 0 ? null : (
<Section title="When it fails" icon={<TriangleAlert className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{fallbacks.map((entry, index) => {
const failure = text(entry.failure);
if (!failure) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{failure}</p>
{/* A response nobody can trigger is not a fallback, so how
the failure is detected is shown before what to do. */}
<Note label="Detection" value={text(entry.detection)} />
<Note label="Response" value={text(entry.response)} />
</li>
);
})}
</ul>
</Section>
)}
{firstThirtyDays.length === 0 ? null : (
<Section title="The first thirty days">
<ol className="min-w-0 space-y-3">
{firstThirtyDays.map((entry, index) => {
const when = text(entry.when);
if (!when) return null;
return (
<li key={index} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{when}</p>
<Note label="Watch" value={text(entry.watch)} />
<Note label="Escalate if" value={text(entry.escalateIf)} tone="warning" />
</li>
);
})}
</ol>
</Section>
)}
{promotion.length === 0 ? null : (
<Section title="What to promote, and when">
<ScrollTable
@@ -624,6 +947,18 @@ function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][]
// -------------------------------------------------------------------- narrowing
/**
* A decision option's verdict colours its badge. The text stays as the author
* wrote it and an unrecognised verdict keeps the neutral tone, so a brief that
* adds a fourth one still reads rather than losing its options to a colour
* lookup that matched nothing.
*/
function verdictTone(verdict: string): 'positive' | 'danger' | 'neutral' {
if (verdict === 'recommended') return 'positive';
if (verdict === 'avoid') return 'danger';
return 'neutral';
}
/**
* A playbook stage names a `DemandStage`, and the label comes from `@pig/core`
* so a renamed stage renames here too. An unrecognised value is shown as
+17 -1
View File
@@ -44,6 +44,7 @@ import {
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
MOTION_KIND_STAGES,
toPiggyPageRoute,
type ArtifactStatus,
type DemandStage,
@@ -400,7 +401,22 @@ export function Engagement() {
// open on the frameworks rather than on whatever was filtered last.
key={instantiating.kind ?? 'any'}
engagementId={engagement.id}
defaultStage={engagement.stage}
/*
* The deal's stage seeds the filter, but only where the forced kind
* actually serves it. The service ANDs kind and stage, so "Add a
* framework" on a deal at POC asked for a qualification template at
* POC — a pair no shipped template can satisfy — and the picker said
* "No template matches" over a library that has three. Where the two
* are coherent the stage is kept, because an architecture prefilter
* on a deal at POC should still open on POC.
*/
defaultStage={
instantiating.kind
? engagement.stage && MOTION_KIND_STAGES[instantiating.kind].includes(engagement.stage)
? engagement.stage
: null
: engagement.stage
}
defaultKind={instantiating.kind}
open
onOpenChange={(open) => {
+34 -1
View File
@@ -394,6 +394,7 @@ function OpenEngagementSheet({
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
const failed = deals.isError || allEngagements.isError;
const create = useMutation({
mutationFn: () =>
@@ -445,7 +446,39 @@ function OpenEngagementSheet({
</SelectGroup>
</SelectContent>
</Select>
{!deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
{/*
A failed read is not an empty book. Without this the sheet told
people "every demand deal already has an engagement" when the deal
query had simply errored. The engagement query's failure has to be
said too rather than tolerated: `taken` is then empty, so the list
is every deal including the ones already running, and choosing one
of those can only answer 409.
*/}
{failed ? (
<>
<p className="text-xs text-danger">
{(deals.error ?? allEngagements.error)?.message ??
'The deal list could not be loaded.'}
</p>
<p className="text-xs text-muted">
{deals.isError
? 'No deal can be offered until this loads.'
: 'Deals that already have an engagement cannot be filtered out, so a choice here may be refused.'}
</p>
<Button
type="button"
variant="outline"
className="w-fit"
onClick={() => {
void deals.refetch();
void allEngagements.refetch();
}}
>
<RefreshCw aria-hidden />
Try again
</Button>
</>
) : !deals.isLoading && !allEngagements.isLoading && available.length === 0 ? (
<p className="text-xs text-muted">
Every demand deal already has an engagement. Open one from the deal in Pipeline
once there is a new deal to run.
+28
View File
@@ -403,8 +403,36 @@ export function MotionTemplate() {
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
{/*
Pending and failed are answered before empty. "No open engagement to
instantiate into" is a claim about the book, and it was being made
while the query was still in flight and again when it had failed —
so a reader was told to go and open an engagement they already have.
`isPending` is a safe reading of "still loading" here only because
both halves of this query's `enabled` are already established:
`mayWrite` by the arm above, `id` by the detail data this branch is
rendered from.
*/}
{!mayWrite ? (
<p className="text-sm text-muted">{WRITE_DENIED}</p>
) : engagements.isPending ? (
<Select disabled>
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
<SelectValue placeholder="Loading engagements…" />
</SelectTrigger>
</Select>
) : engagements.isError ? (
<>
<p className="text-sm text-muted">
{engagements.error instanceof Error
? engagements.error.message
: 'The open engagements could not be loaded.'}
</p>
<Button variant="outline" onClick={() => void engagements.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
</>
) : openEngagements.length === 0 ? (
<p className="text-sm text-muted">
No open engagement to instantiate into.{' '}