Add Motion: the go-to-market operating system on top of the ledger

The ledger answers which contracted capacity is sold, to whom, at what
margin. It says nothing about the motion — the repeatable practice that
turns a customer conversation into a scoped deployment, and turns that
deployment into something the next one reuses.

Motion is deliberately not a parallel entity tree. DEMAND_STAGES already
is the motion, so Motion binds reusable artefacts to the stages of a
demand deal that already exists: an engagement hangs off one deal,
cascade deleted, one per deal by unique constraint.

Nine closed kinds, each declaring which stages it serves, and a starter
library of twelve templates covering all eight open stages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-17 18:27:03 -07:00
parent 99d165b5e5
commit 516685526c
61 changed files with 13013 additions and 29 deletions
+18 -5
View File
@@ -152,14 +152,27 @@ jobs:
- name: Seed is idempotent
# A seed that duplicates on a second run corrupts any database it is
# pointed at twice, and nobody notices until the counts look odd.
#
# Two tables are counted, not one. This gate only ever watched
# `contacts`, and `contacts` is idempotent by an explicit existence
# check — so it could not see the class of regression it exists to
# catch, which is `onConflictDoNothing` firing at a constraint that is
# no longer there. The Motion starter library relies on exactly that
# clause against `motion_templates_slug_version_key`, so it is counted
# here too. Any table whose idempotency rests on a conflict target
# belongs in this list.
run: |
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from contacts"; }
BEFORE=$(count)
count() { docker exec "$PG_CONTAINER" psql -U pig -d pig -tAc "select count(*) from $1"; }
CONTACTS_BEFORE=$(count contacts)
TEMPLATES_BEFORE=$(count motion_templates)
pnpm exec tsx packages/db/src/seed/index.ts > /dev/null
AFTER=$(count)
echo "contacts: $BEFORE -> $AFTER"
test "$BEFORE" = "$AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
CONTACTS_AFTER=$(count contacts)
TEMPLATES_AFTER=$(count motion_templates)
echo "contacts: $CONTACTS_BEFORE -> $CONTACTS_AFTER"
echo "motion_templates: $TEMPLATES_BEFORE -> $TEMPLATES_AFTER"
test "$CONTACTS_BEFORE" = "$CONTACTS_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
test "$TEMPLATES_BEFORE" = "$TEMPLATES_AFTER" || { echo "SEED IS NOT IDEMPOTENT"; exit 1; }
- name: Critical path E2E against Postgres and Hono
run: pnpm run test:e2e
+29 -11
View File
@@ -31,19 +31,21 @@ Everything else is plumbing that exists to keep that ledger honest.
```
packages/core Ontology (stages, tiers, enums) + permissions + margin + palette
packages/db Drizzle schema (47 tables), migrations, seeds
+ motion.ts (template kinds, integer qualification scoring)
packages/db Drizzle schema (51 tables), migrations, seeds
schema/motion.ts — the library, engagements and the loop
packages/prime Typed client for the Prime Intellect compute API
apps/api Hono HTTP API, auth, capacity/contract/calendar services
apps/api Hono HTTP API, auth, capacity/contract/calendar/motion services
apps/web React + Vite + Tailwind + shadcn-idiom components
apps/piggy The agent — lease-based queue worker + private chat server
apps/mcp MCP server (stdio) — 9 tools
apps/mcp MCP server (stdio) — 10 tools
apps/cli `pig`, the HTTP surface for scripts and agent kernels
docs/ ontology.md, build-plan.md, agents.md, seed-data.md
docs/ ontology.md, motion.md, build-plan.md, agents.md, seed-data.md
deploy/ README.md (deployment), Caddyfile example, autodeploy units
```
~45,000 lines including tests. 261 tests across five packages
(core 62, prime 24, api 157, piggy 13, cli 5), plus a critical-path E2E suite
~48,000 lines including tests. 382 tests across five packages
(core 78, prime 24, api 212, piggy 63, cli 5), plus a critical-path E2E suite
under `apps/api/e2e`. Node 22+.
| | |
@@ -231,11 +233,27 @@ Pass `reasoning_effort: "none"` for tool use, routing and extraction.
**A route file with green tests can still be unmounted.** Every route module is
a factory returning a `Hono` app, and `createApp` has to call it. The tests
mount the factory themselves, so they pass whether or not `app.ts` ever does.
Four modules are in exactly that state right now — `read-guards.ts`,
`learn.ts`, `hubspot.ts`, `hubspot-webhook.ts` — which is why read
authorisation is unenforced and `/learn` answers 404 from a page that is in the
navigation. After adding a route file, curl the path against a running server;
the test suite cannot tell you.
`read-guards.ts` and `learn.ts` were in exactly that state — which is why read
authorisation went unenforced and `/learn` answered 404 from a page that was in
the navigation — and are now mounted. `hubspot.ts` and `hubspot-webhook.ts`
still are not. After adding a route file, curl the path against a running
server; the test suite cannot tell you.
**A stale dev server on :8920 makes a mounted route look unmounted.** The curl
check the entry above recommends is only as good as the process answering it. `pnpm run dev:api` prints its `EADDRINUSE` and keeps running under
the process manager, so a server started hours earlier from an older checkout
goes on answering — and every new route 404s with a perfectly plausible
`{"error":"Not found"}` JSON body. Found this way: five Motion routes that were
correctly mounted read as missing for twenty minutes. Check
`ss -lptn 'sport = :8920'` before believing a 404, and read the dev server's log
rather than only its port.
**The demo seed skips an account it has already seen, and the rows hanging off
that account never appear.** `seedDemo` is idempotent per account, so a database
carrying a partial demo book from an earlier run silently produces no demand
deals — and anything that looks a deal up by name, as `seed/demo/motion.ts`
does, then reports zero and reads exactly like a broken loader. The fix is
`pnpm db:demo -- --clear` and a reseed, not a patch to the lookup.
**Deployment traps** live in `deploy/README.md` — chiefly that every Caddy site
block on that host needs `bind 10.0.0.2`, and that the CI runner uses
+5 -3
View File
@@ -173,6 +173,7 @@ thesis:
| `contracts` + `sla_terms` + `sla_metric_targets` + `contract_obligations` | Polymorphic over party and type — MSA, DPA, SLA, order form, capacity commitment — with negotiated SLA terms and dated obligations | The supply side negotiates heavyweight paper; the self-serve demand side runs on a reliability tier and a credits policy instead |
| `export_authorizations`, `compliance_artifacts`, `compliance_decisions` | Export-control determinations recorded **on the allocation edge**, with reasoning and rule version | US controls apply an ultimate-parent test that reaches through the corporate tree, so country of incorporation is not a valid key |
| `facts` | Every agent-derived claim, with score, band, evidence excerpt and source URL | An agent allowed to write unattributed claims will eventually write a wrong one and nobody will be able to tell which |
| `motion_templates` + `engagements` + `engagement_artifacts` + `qualification_scores` | The go-to-market motion: a reusable library bound to the stages of a demand deal, and the artefacts each engagement produced | A used template is never edited in place — promotion writes a **new version** pointing back at the artefact that proved it, which is what makes the next deployment cheaper than the last |
| `agent_tasks` / `agent_runs` / `agent_actions` | The queue the API writes to and the agent drains, plus what it did | The API never calls the model; it writes a row |
Two pipelines, with stages taken from how the market operates:
@@ -351,13 +352,13 @@ apps/
web/ React 19 + Vite + Tailwind + shadcn-idiom components
api/ Hono HTTP API — auth, validation, capacity and contract services
piggy/ The agent: a lease-based queue worker plus a private chat server
mcp/ MCP server (stdio) — 9 tools
mcp/ MCP server (stdio) — 10 tools
cli/ `pig`, the HTTP surface for scripts and agent kernels
packages/
core/ Ontology, permissions, margin arithmetic, palette — no I/O
db/ Drizzle schema (47 tables), 14 migrations, seed and demo data
db/ Drizzle schema (51 tables), 15 migrations, seed and demo data
prime/ Typed client for the Prime Intellect compute API
docs/ ontology.md, screenshots.md, build-plan.md, agents.md, seed-data.md
docs/ ontology.md, motion.md, screenshots.md, build-plan.md, agents.md, seed-data.md
deploy/ Caddyfile example, autodeploy units, deployment notes
```
@@ -590,6 +591,7 @@ responsive to 393px; it is not a native app.
- [Screenshots](./docs/screenshots.md) — every page, at 1440px and 393px, light and dark
- [Ontology](./docs/ontology.md) — the domain model, and why it is shaped this way
- [Build plan](./docs/build-plan.md) — what shipped, what remains, in dependency order
- [Motion](./docs/motion.md) — the template library, the promotion loop, and the private/shared departure
- [Agent integration](./docs/agents.md) — MCP clients and the CLI
- [Seed data provenance](./docs/seed-data.md) — every claim, graded and cited
- [Deployment](./deploy/README.md) — self-hosting, the release poller, rollback
+2
View File
@@ -72,6 +72,7 @@ import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/n
import { createGrowthRoutes } from './routes/growth';
import { createCalendarRoutes } from './routes/calendar';
import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn';
import { createMotionRoutes } from './routes/motion';
import { createReadGuardRoutes } from './routes/read-guards';
import { createActivityRoutes } from './routes/activities';
import { NotificationOutbox } from './services/notification-outbox';
@@ -266,6 +267,7 @@ export function createApp(
app.route('/', createGrowthRoutes(db));
app.route('/', createCalendarRoutes(db));
app.route('/', createLearnRoutes(db));
app.route('/', createMotionRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
+933
View File
@@ -0,0 +1,933 @@
/**
* Motion the HTTP surface for the library, the engagements and the loop.
*
* Thin by design. Every decision worth arguing about who may see a private
* template, what promotion writes, how a score is computed is in
* `services/motion.ts`, so Piggy reaches the same answers in-process without
* going through a handler. The writes are exported `MutationDefinition`
* factories in the register of `capacity-writes.ts`, which is what lets a test
* drive the rules in §7 without an HTTP server or a database.
*
* Two things here are not obvious from the endpoint table.
*
* **`motion:write` and `motion:publish` are team capabilities held on any
* team**, so they are enforced with `requireAnyTeamCapability` rather than a
* `{ capability, team }` pair. A motion template belongs to a person and a
* lineage, not to supply or demand; picking a team to check against would mean
* inventing one, and inventing one is how a research lead ends up unable to
* publish the reference architecture they are the one writing.
*
* **Creating or forking straight to `shared` needs `motion:publish`.** The
* publish endpoint is not the only way into the shared library `POST
* /templates` and `POST /templates/:id/versions` both take a `visibility` so
* the gate is applied in all three places rather than on the one door that
* happens to be named after it.
*/
import {
ARTIFACT_STATUSES,
DEMAND_STAGES,
ENGAGEMENT_STATUSES,
MOTION_KINDS,
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
MOTION_VISIBILITIES,
motionBand,
permissionGranted,
resolveWritePermissionGrants,
} from '@pig/core';
import type {
Database,
Engagement,
EngagementArtifact,
MotionTemplate,
QualificationScore,
} from '@pig/db';
import { demandDeals, engagementArtifacts, engagements, motionTemplates } from '@pig/db';
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireAnyTeamCapability, type Principal } from '../lib/auth';
import {
apiError,
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import {
assertTemplateEditable,
assertTemplateWritable,
instantiateArtifact,
loadEngagement,
loadTemplateForWrite,
lockNewestVersion,
motionSlug,
MotionService,
newestVisibleInLineage,
ownsTemplate,
promoteArtifact,
recordScore,
versionConflict,
type MotionTransaction,
type MotionViewer,
} from '../services/motion';
// ------------------------------------------------------------------- schemas
const uuid = z.string().uuid();
const title = z.string().trim().min(1).max(200);
const summary = z.string().trim().min(1).max(2_000);
/** Markdown, and long: a playbook is a document, not a field. */
const bodyText = z.string().max(200_000);
const slug = z.string().trim().min(1).max(80);
const fields = z.record(z.unknown()).nullable();
const templateCreateSchema = z
.object({
kind: z.enum(MOTION_KINDS),
slug: slug.optional(),
title,
summary,
body: bodyText,
stage: z.enum(DEMAND_STAGES),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
/**
* No `kind` and no `visibility`. A lineage that changes kind halfway is a
* different template wearing the same slug, and visibility moves through
* `/publish`, which is where the capability check lives.
*/
const templateUpdateSchema = z
.object({ title, summary, body: bodyText, stage: z.enum(DEMAND_STAGES), fields })
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const templateVersionSchema = z
.object({
title: title.optional(),
summary: summary.optional(),
body: bodyText.optional(),
stage: z.enum(DEMAND_STAGES).optional(),
fields: fields.optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
})
.strict();
const emptySchema = z.object({}).strict();
const engagementCreateSchema = z
.object({
demandDealId: uuid,
playbookTemplateId: uuid.nullable().optional(),
ownerUserId: uuid.nullable().optional(),
summary: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const engagementUpdateSchema = z
.object({
status: z.enum(ENGAGEMENT_STATUSES),
ownerUserId: uuid.nullable(),
playbookTemplateId: uuid.nullable(),
summary: z.string().trim().max(2_000).nullable(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const artifactCreateSchema = z
.object({
templateId: uuid.optional(),
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
title: title.optional(),
body: bodyText.optional(),
fields: fields.optional(),
status: z.enum(ARTIFACT_STATUSES).optional(),
})
.strict()
.refine(
(input) =>
Boolean(input.templateId) ||
Boolean(input.kind && input.stage && input.title && input.body !== undefined),
'Supply a templateId to instantiate, or a kind, stage, title and body.',
);
const artifactUpdateSchema = z
.object({
title,
body: bodyText,
fields,
stage: z.enum(DEMAND_STAGES),
status: z.enum(ARTIFACT_STATUSES),
archived: z.boolean(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const promoteSchema = z
.object({ slug: slug.optional(), title: title.optional(), summary: summary.optional() })
.strict();
/**
* Weights and scores are integers, and the total is recomputed server-side from
* these the client never posts a score. Bounds come from `@pig/core` so the
* scale is defined in one place.
*/
const scoreSchema = z
.object({
dimensions: z
.array(
z
.object({
id: z.string().trim().min(1).max(120),
weight: z.number().int().min(0).max(1_000),
score: z
.number()
.int()
.min(MOTION_MIN_DIMENSION_SCORE)
.max(MOTION_MAX_DIMENSION_SCORE),
})
.strict(),
)
.max(50),
frameworkTemplateId: uuid.nullable().optional(),
note: z.string().trim().max(2_000).nullable().optional(),
})
.strict();
const filtersSchema = z
.object({
kind: z.enum(MOTION_KINDS).optional(),
stage: z.enum(DEMAND_STAGES).optional(),
visibility: z.enum(MOTION_VISIBILITIES).optional(),
q: z.string().trim().max(200).optional(),
all: z.string().optional(),
})
.strict();
const engagementFiltersSchema = z
.object({ status: z.enum(ENGAGEMENT_STATUSES).optional() })
.strict();
// ------------------------------------------------------------------- helpers
function viewerOf(principal: Principal): MotionViewer {
return { userId: principal.userId, isPlatformAdmin: principal.isPlatformAdmin };
}
const authorizeWrite = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:write'),
};
const authorizePublish = {
authorize: (p: Principal) => requireAnyTeamCapability(p, 'motion:publish'),
};
/**
* A template id somebody sent, checked before it is stored.
*
* `playbook_template_id` and `framework_template_id` are provenance, and they
* are returned book-wide on every engagement summary. Written raw, an id
* nobody but its owner may read becomes public through the engagement that
* cites it, and a wrong id is a foreign-key 500 rather than a 404. Reading it
* through the visibility filter makes both cases a clean not-found.
*/
async function checkedTemplateId(
tx: MotionTransaction,
principal: Principal,
id: string | null | undefined,
): Promise<void> {
if (!id) return;
await loadTemplateForWrite(tx, viewerOf(principal), id);
}
/** The same gate as `authorizePublish`, applied once the body says `shared`. */
function requirePublishFor(principal: Principal, visibility: string | undefined): void {
if (visibility === 'shared') requireAnyTeamCapability(principal, 'motion:publish');
}
/**
* The hint the detail page renders its Publish button from. It checks the
* `write` scope as well as the capability, because `requireAnyTeamCapability`
* does a read-only API key belonging to a lead would otherwise be told it may
* publish and then be refused `insufficient_scope` by the endpoint itself.
*/
function canPublish(principal: Principal): boolean {
return (
principal.scopes.includes('write') &&
permissionGranted(resolveWritePermissionGrants(principal), 'motion:publish')
);
}
function requiredId(params: Readonly<Record<string, string>>, resource: string): string {
const id = params.id;
if (!id) throw MutationError.notFound(resource);
return id;
}
// ------------------------------------------------------------ library writes
export function motionTemplateCreateDefinition(): MutationDefinition<
typeof templateCreateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template.',
async mutate({ input, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const slug = motionSlug(input.slug ?? input.title);
// Checked rather than left to `(slug, version)`, for the reason the
// engagement create gives below: a title that derives an existing slug —
// "Proposal Blocks" is one of the nine shipped starters — would otherwise
// be a 500 with `Internal error` and no way for the caller to tell what
// they collided with.
if (await lockNewestVersion(tx, slug)) {
throw new MutationError(
'template_slug_exists',
`A template lineage already uses the slug "${slug}". POST /api/motion/templates/<id>/versions to add a version to it, or supply a different slug.`,
409,
);
}
const [created] = await tx
.insert(motionTemplates)
.values({
kind: input.kind,
slug,
version: 1,
title: input.title,
summary: input.summary,
body: input.body,
fields: input.fields ?? null,
stage: input.stage,
visibility: input.visibility ?? 'private',
// Never null. The CHECK refuses an unowned private row, and an
// unowned private row is one nobody could ever read back.
ownerUserId: principal.userId,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `Added a motion template: ${created.title}`,
meta: {
action: 'motion_template.created',
templateId: created.id,
kind: created.kind,
slug: created.slug,
visibility: created.visibility,
},
},
};
},
};
}
export function motionTemplateUpdateDefinition(): MutationDefinition<
typeof templateUpdateSchema,
{ template: MotionTemplate }
> {
return {
schema: templateUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template change.',
async mutate({ input, params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
assertTemplateEditable(existing);
const [updated] = await tx
.update(motionTemplates)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
updatedAt: now,
})
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Motion template');
return {
data: { template: updated },
activity: {
type: 'note',
subject: `Edited a motion template: ${updated.title}`,
meta: {
action: 'motion_template.updated',
templateId: updated.id,
fields: Object.keys(input),
},
},
};
},
};
}
/**
* A new version of a lineage the answer to a `409 template_in_use`, and the
* "fork to private" affordance, in one endpoint.
*
* Ownership is deliberately NOT required: a shared template belongs to the
* book, and forking it into your own private draft is what a library is for.
* What is required is `motion:publish` if the fork is to land shared.
*/
export function motionTemplateVersionDefinition(): MutationDefinition<
typeof templateVersionSchema,
{ template: MotionTemplate }
> {
return {
schema: templateVersionSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid motion template version.',
async mutate({ input, params, principal, tx, now }) {
requirePublishFor(principal, input.visibility);
const viewer = viewerOf(principal);
const source = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
// Two different questions, deliberately answered by two queries. The
// version has to be allocated against the whole lineage or it collides
// with a private fork nobody else can see, but `supersedes_id` must name
// a row this caller could actually fetch — otherwise a fork of a shared
// v1 comes back claiming to supersede somebody's private v4, which both
// leaks that the private versions exist and records a lineage edge that
// is not the one the user made.
const newest = await lockNewestVersion(tx, source.slug);
const previous = (await newestVisibleInLineage(tx, viewer, source.slug)) ?? source;
const [created] = await tx
.insert(motionTemplates)
.values({
kind: source.kind,
slug: source.slug,
version: (newest?.version ?? source.version) + 1,
title: input.title ?? source.title,
summary: input.summary ?? source.summary,
body: input.body ?? source.body,
fields: input.fields !== undefined ? input.fields : source.fields,
stage: input.stage ?? source.stage,
// Private unless asked otherwise, so forking a shared template is
// never accidentally a publication.
visibility: input.visibility ?? 'private',
ownerUserId: principal.userId,
supersedesId: previous.id,
isSystem: false,
createdAt: now,
updatedAt: now,
})
.returning()
.catch(versionConflict);
if (!created) throw new Error('Motion template version insert returned no row');
return {
data: { template: created },
activity: {
type: 'note',
subject: `New version of ${created.title} (v${created.version})`,
meta: {
action: 'motion_template.versioned',
templateId: created.id,
supersedesId: previous.id,
slug: created.slug,
version: created.version,
},
},
};
},
};
}
export function motionTemplatePublishDefinition(): MutationDefinition<
typeof emptySchema,
{ template: MotionTemplate }
> {
return {
schema: emptySchema,
permission: authorizePublish,
invalidMessage: 'Invalid publish request.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
// Idempotent: publishing twice is the same statement made twice, and a
// 409 here would only ever fire on a double-clicked button.
const [published] =
existing.visibility === 'shared'
? [existing]
: await tx
.update(motionTemplates)
.set({ visibility: 'shared', updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!published) throw MutationError.notFound('Motion template');
return {
data: { template: published },
activity: {
type: 'note',
subject: `Published to the library: ${published.title} (v${published.version})`,
meta: {
action: 'motion_template.published',
templateId: published.id,
alreadyShared: existing.visibility === 'shared',
},
},
};
},
};
}
/**
* Archive, never delete: an engagement artifact records the template it came
* from, and provenance that can vanish is not provenance.
*/
export function motionTemplateArchiveDefinition(): MutationDefinition<
typeof emptySchema,
{ id: string; archivedAt: Date | null }
> {
return {
schema: emptySchema,
permission: authorizeWrite,
invalidMessage: 'Invalid template removal.',
async mutate({ params, principal, tx, now }) {
const viewer = viewerOf(principal);
const existing = await loadTemplateForWrite(
tx,
viewer,
requiredId(params, 'Motion template'),
);
assertTemplateWritable(viewer, existing);
const [archived] = existing.archivedAt
? [existing]
: await tx
.update(motionTemplates)
.set({ archivedAt: now, updatedAt: now })
.where(eq(motionTemplates.id, existing.id))
.returning();
if (!archived) throw MutationError.notFound('Motion template');
return {
data: { id: archived.id, archivedAt: archived.archivedAt },
activity: {
type: 'note',
subject: `Archived a motion template: ${archived.title}`,
meta: {
action: 'motion_template.archived',
templateId: archived.id,
alreadyArchived: existing.archivedAt !== null,
},
},
};
},
};
}
// --------------------------------------------------------- engagement writes
export function motionEngagementCreateDefinition(): MutationDefinition<
typeof engagementCreateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement.',
async mutate({ input, principal, tx, now }) {
const [deal] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, input.demandDealId))
.limit(1);
if (!deal) throw MutationError.notFound('Demand deal');
await checkedTemplateId(tx, principal, input.playbookTemplateId);
// Checked rather than left to the unique constraint, so the caller gets
// the id of the engagement that already exists instead of a 500.
const [existing] = await tx
.select()
.from(engagements)
.where(eq(engagements.demandDealId, deal.id))
.limit(1);
if (existing) {
throw new MutationError(
'engagement_exists',
`This deal already has an engagement (${existing.id}).`,
409,
);
}
const [created] = await tx
.insert(engagements)
.values({
demandDealId: deal.id,
playbookTemplateId: input.playbookTemplateId ?? null,
ownerUserId: input.ownerUserId ?? principal.userId,
summary: input.summary ?? null,
openedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new Error('Engagement insert returned no row');
return {
data: { engagement: created },
activity: {
type: 'note',
subject: `Opened an engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: { action: 'engagement.opened', engagementId: created.id },
},
};
},
};
}
export function motionEngagementUpdateDefinition(): MutationDefinition<
typeof engagementUpdateSchema,
{ engagement: Engagement }
> {
return {
schema: engagementUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement change.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.playbookTemplateId);
const [updated] = await tx
.update(engagements)
.set({
...(input.status !== undefined ? { status: input.status } : {}),
...(input.ownerUserId !== undefined ? { ownerUserId: input.ownerUserId } : {}),
...(input.playbookTemplateId !== undefined
? { playbookTemplateId: input.playbookTemplateId }
: {}),
...(input.summary !== undefined ? { summary: input.summary } : {}),
// `closed_at` follows the status rather than being sent, so a
// reopened engagement cannot keep a close date it no longer has.
...(input.status !== undefined
? { closedAt: input.status === 'won' || input.status === 'lost' ? now : null }
: {}),
updatedAt: now,
})
.where(eq(engagements.id, engagement.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement');
return {
data: { engagement: updated },
activity: {
type: 'note',
subject: `Updated the engagement on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement.updated',
engagementId: updated.id,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactCreateDefinition(): MutationDefinition<
typeof artifactCreateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactCreateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid engagement artefact.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
const { artifact, template } = await instantiateArtifact(
tx,
viewerOf(principal),
{ ...input, engagementId: engagement.id },
now,
);
return {
data: { artifact },
activity: {
type: 'note',
subject: template
? `Instantiated ${template.title} (v${template.version}) on ${deal.name}`
: `Added a ${artifact.kind} artefact to ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.created',
engagementId: engagement.id,
artifactId: artifact.id,
templateId: template?.id ?? null,
kind: artifact.kind,
stage: artifact.stage,
},
},
};
},
};
}
/**
* Not restricted to the author: an engagement is worked by a team, and an
* artifact nobody but its author can finish is one that stalls the week they go
* on leave. The library which everybody copies is what ownership guards.
*/
export function motionArtifactUpdateDefinition(): MutationDefinition<
typeof artifactUpdateSchema,
{ artifact: EngagementArtifact }
> {
return {
schema: artifactUpdateSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid artefact change.',
async mutate({ input, params, tx, now }) {
const id = requiredId(params, 'Engagement artefact');
const [existing] = await tx
.select()
.from(engagementArtifacts)
.where(eq(engagementArtifacts.id, id))
.limit(1);
if (!existing) throw MutationError.notFound('Engagement artefact');
const { deal } = await loadEngagement(tx, existing.engagementId);
const [updated] = await tx
.update(engagementArtifacts)
.set({
...(input.title !== undefined ? { title: input.title } : {}),
...(input.body !== undefined ? { body: input.body } : {}),
...(input.fields !== undefined ? { fields: input.fields } : {}),
...(input.stage !== undefined ? { stage: input.stage } : {}),
...(input.status !== undefined ? { status: input.status } : {}),
...(input.archived !== undefined ? { archivedAt: input.archived ? now : null } : {}),
updatedAt: now,
})
.where(eq(engagementArtifacts.id, existing.id))
.returning();
if (!updated) throw MutationError.notFound('Engagement artefact');
return {
data: { artifact: updated },
activity: {
type: 'note',
subject: `Updated ${updated.title} on ${deal.name}`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'engagement_artifact.updated',
artifactId: updated.id,
engagementId: updated.engagementId,
fields: Object.keys(input),
status: updated.status,
},
},
};
},
};
}
export function motionArtifactPromoteDefinition(): MutationDefinition<
typeof promoteSchema,
{ template: MotionTemplate; artifact: EngagementArtifact }
> {
return {
schema: promoteSchema,
permission: authorizePublish,
invalidMessage: 'Invalid promotion.',
async mutate({ input, params, principal, tx, now }) {
const result = await promoteArtifact(
tx,
viewerOf(principal),
requiredId(params, 'Engagement artefact'),
input,
now,
);
return {
data: { template: result.template, artifact: result.artifact },
activity: {
type: 'note',
subject: `Promoted to the library: ${result.template.title} (v${result.template.version})`,
// The account, so the loop lands on the timeline of the deal that
// proved it rather than only in the library.
accountId: result.engagement.deal.accountId,
demandDealId: result.engagement.deal.id,
meta: {
action: 'engagement_artifact.promoted',
artifactId: result.artifact.id,
engagementId: result.engagement.engagement.id,
templateId: result.template.id,
supersedesId: result.supersedes?.id ?? null,
slug: result.template.slug,
version: result.template.version,
},
},
};
},
};
}
export function motionScoreDefinition(): MutationDefinition<
typeof scoreSchema,
{ score: QualificationScore }
> {
return {
schema: scoreSchema,
permission: authorizeWrite,
invalidMessage: 'Invalid qualification score.',
async mutate({ input, params, principal, tx, now }) {
const { engagement, deal } = await loadEngagement(tx, requiredId(params, 'Engagement'));
await checkedTemplateId(tx, principal, input.frameworkTemplateId);
const score = await recordScore(tx, viewerOf(principal), engagement.id, input, now);
return {
data: { score },
activity: {
type: 'note',
// Rounded, never truncated — the same rule money follows, for the
// same reason: this line is the only score most people will read.
subject: `Qualified ${deal.name}: ${score.band} (${Math.round(score.basisPoints / 100)}%)`,
accountId: deal.accountId,
demandDealId: deal.id,
meta: {
action: 'qualification_score.recorded',
engagementId: engagement.id,
scoreId: score.id,
basisPoints: score.basisPoints,
band: score.band,
tone: motionBand(score.basisPoints).tone,
},
},
};
},
};
}
// -------------------------------------------------------------------- routes
export function createMotionRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
const motion = new MotionService(db);
routes.get('/api/motion', async (c) =>
c.json(await motion.overview(viewerOf(c.get('principal')))),
);
routes.get('/api/motion/templates', async (c) => {
const parsed = filtersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid library filter.', parsed.error.issues),
400,
);
}
const { all, ...filters } = parsed.data;
return c.json(
await motion.listTemplates(viewerOf(c.get('principal')), { ...filters, all: all === '1' }),
);
});
routes.get('/api/motion/templates/:id', async (c) => {
const principal = c.get('principal');
const found = await motion.template(viewerOf(principal), c.req.param('id'));
// A private template belonging to somebody else answers exactly as an
// unknown id does. Anything else confirms that it exists.
if (!found) return c.json(apiError('not_found', 'Motion template not found.'), 404);
const owned = ownsTemplate(viewerOf(principal), found.template);
return c.json({
...found,
/** The server's judgement, so the page does not re-derive §7a in TSX. */
canEdit: owned && found.template.usageCount === 0,
canPublish: owned && found.template.visibility === 'private' && canPublish(principal),
});
});
routes.get('/api/motion/engagements', async (c) => {
const parsed = engagementFiltersSchema.safeParse(c.req.query());
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid engagement filter.', parsed.error.issues),
400,
);
}
return c.json({ engagements: await motion.listEngagements(parsed.data.status) });
});
routes.get('/api/motion/engagements/:id', async (c) => {
const found = await motion.engagement(viewerOf(c.get('principal')), c.req.param('id'));
if (!found) return c.json(apiError('not_found', 'Engagement not found.'), 404);
return c.json(found);
});
routes.post('/api/motion/templates', mutation(db, motionTemplateCreateDefinition()));
routes.patch('/api/motion/templates/:id', mutation(db, motionTemplateUpdateDefinition()));
routes.post(
'/api/motion/templates/:id/versions',
mutation(db, motionTemplateVersionDefinition()),
);
routes.post(
'/api/motion/templates/:id/publish',
bodylessMutation(db, motionTemplatePublishDefinition()),
);
routes.delete(
'/api/motion/templates/:id',
bodylessMutation(db, motionTemplateArchiveDefinition()),
);
routes.post('/api/motion/engagements', mutation(db, motionEngagementCreateDefinition()));
routes.patch('/api/motion/engagements/:id', mutation(db, motionEngagementUpdateDefinition()));
routes.post(
'/api/motion/engagements/:id/artifacts',
mutation(db, motionArtifactCreateDefinition()),
);
routes.patch('/api/motion/artifacts/:id', mutation(db, motionArtifactUpdateDefinition()));
routes.post(
'/api/motion/artifacts/:id/promote',
mutation(db, motionArtifactPromoteDefinition()),
);
routes.post(
'/api/motion/engagements/:id/scores',
mutation(db, motionScoreDefinition()),
);
return routes;
}
+26
View File
@@ -58,6 +58,22 @@ export const READ_RULES: readonly ReadRule[] = [
{ method: 'GET', path: '/api/growth/accounts/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/facts', capability: 'book:read' },
/**
* The motion library is not the cost book. A proposal block, a reference
* architecture and a qualification framework carry no supplier cost and no
* break-even, so gating them on `economics:read` would keep a research lead
* out of the reference architectures they are the ones writing.
*
* The private/shared split is NOT enforced here and could not be: this table
* is keyed on a path and knows nothing about rows. It is a real WHERE clause
* in `services/motion.ts`, which every one of these reads goes through.
*/
{ method: 'GET', path: '/api/motion', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/templates/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements', capability: 'book:read' },
{ method: 'GET', path: '/api/motion/engagements/:id', capability: 'book:read' },
{ method: 'GET', path: '/api/team', capability: 'team:read' },
/**
@@ -120,6 +136,16 @@ const PIGGY_PAGE_CAPABILITIES: Readonly<Record<PiggyPageRoute, ReadCapability>>
'/supply': 'book:read',
'/calendar': 'book:read',
'/contracts': 'book:read',
// The motion library is not the cost book. pig_get_motion_summary,
// pig_search_motion_library and pig_get_engagement return templates,
// artifacts and qualification scores — authored practice, carrying no
// supplier cost and no break-even — so the classification follows what those
// tools return rather than what the pages look like. The private/shared rule
// is a separate matter and is enforced in the query, not here: the library
// tool filters to `visibility = 'shared'` unconditionally.
'/motion': 'book:read',
'/motion/library': 'book:read',
'/motion/engagements': 'book:read',
};
const PIGGY_RECORD_CAPABILITIES: Readonly<Record<PiggyRecordType, ReadCapability>> = {
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
/**
* Motion the two rules that make this a system, and the one departure that
* makes it a risk.
*
* The departure first. `permissions.ts` states that every read endpoint returns
* the whole book because no row-level filter exists anywhere in the query
* layer; motion templates are the first exception, and an exception that is
* only enforced by the UI is not an exception, it is a leak with a nice screen
* in front of it. So the first suite renders the actual predicate to SQL and
* asserts on it. It looks like a test of an implementation detail and is not:
* the WHERE clause IS the access policy, and the two ways of getting it wrong
* dropping the owner comparison, or widening it with `OR owner IS NULL` both
* produce a query that returns rows and reports nothing.
*
* The rest drive the exported mutation definitions against a scripted
* transaction, in the register of `activities.test.ts`. The fake answers
* queries in call order and records what ran; it is deliberately not a
* database, because a fake that pretends to run SQL is a fake that will one day
* assert a broken query works.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { getTableName, isSQLWrapper, isTable, type SQL } from 'drizzle-orm';
import { PgDialect } from 'drizzle-orm/pg-core';
import { motionScoreBasisPoints } from '@pig/core';
import type { Database, MotionTemplate } from '@pig/db';
import { Hono } from 'hono';
import { AuthError, type Principal } from '../src/lib/auth';
import { executeMutation, MutationError, type ApiEnv } from '../src/lib/mutation';
import {
createMotionRoutes,
motionArtifactCreateDefinition,
motionArtifactPromoteDefinition,
motionEngagementCreateDefinition,
motionEngagementUpdateDefinition,
motionScoreDefinition,
motionTemplateCreateDefinition,
motionTemplatePublishDefinition,
motionTemplateUpdateDefinition,
motionTemplateVersionDefinition,
} from '../src/routes/motion';
import { motionSlug, visibleTemplates } from '../src/services/motion';
import { onTeam, principal } from './helpers/principal';
const OWNER = '00000000-0000-4000-8000-000000000001';
const OTHER = '00000000-0000-4000-8000-0000000000aa';
const DEAL_ID = '00000000-0000-4000-8000-0000000000d1';
const ACCOUNT_ID = '00000000-0000-4000-8000-0000000000ac';
const ENGAGEMENT_ID = '00000000-0000-4000-8000-0000000000e1';
const ARTIFACT_ID = '00000000-0000-4000-8000-0000000000f1';
const TEMPLATE_ID = '00000000-0000-4000-8000-0000000000b1';
const NOW = new Date('2026-08-17T09:00:00.000Z');
/** A demand member: `motion:write`, no `motion:publish`. */
const member = principal(onTeam('demand', 'member'));
/** A demand lead: both, which is the point of the split. */
const lead = principal(onTeam('demand', 'lead'));
// --------------------------------------------------------------- the fixtures
function template(overrides: Partial<MotionTemplate> = {}): MotionTemplate {
return {
id: TEMPLATE_ID,
kind: 'poc',
slug: 'poc-plan',
version: 1,
title: 'POC plan',
summary: 'What a proof of concept must show.',
body: '# POC plan',
fields: null,
stage: 'poc',
visibility: 'private',
ownerUserId: OWNER,
supersedesId: null,
originArtifactId: null,
isSystem: false,
usageCount: 0,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
const deal = { id: DEAL_ID, accountId: ACCOUNT_ID, name: 'DEMO — Northwind training' };
const engagement = { id: ENGAGEMENT_ID, demandDealId: DEAL_ID, status: 'open' };
function artifact(overrides: Record<string, unknown> = {}) {
return {
id: ARTIFACT_ID,
engagementId: ENGAGEMENT_ID,
templateId: null,
kind: 'poc',
stage: 'poc',
title: 'Northwind POC plan',
body: '# What we proved',
fields: null,
status: 'final',
authoredByUserId: OWNER,
promotedTemplateId: null,
archivedAt: null,
createdAt: NOW,
updatedAt: NOW,
...overrides,
};
}
// ------------------------------------------------------------------ the fake
interface Recorded {
events: string[];
inserted: { table: string; row: Record<string, unknown> }[];
updated: { table: string; values: Record<string, unknown> }[];
}
/**
* A transaction that answers selects from a script, in call order, and records
* every write with the table it landed in. Order-dependence is the price of not
* pretending to be Postgres, and it is what makes "no UPDATE ran" assertable.
*/
function database(script: unknown[][]): { db: Database; log: Recorded } {
const log: Recorded = { events: [], inserted: [], updated: [] };
const results = [...script];
const next = (): unknown[] => results.shift() ?? [];
const selectChain = {
from: () => selectChain,
innerJoin: () => selectChain,
leftJoin: () => selectChain,
where: () => selectChain,
orderBy: () => selectChain,
groupBy: () => selectChain,
// `limit` returns the chain rather than a promise so that `.for('update')`
// can follow it, as it does on every query in this feature that allocates
// a version number. The chain is a thenable, so `await` still ends it.
limit: () => selectChain,
for: () => selectChain,
then: (resolve: (rows: unknown[]) => unknown) => resolve(next()),
};
const name = (table: unknown): string => (isTable(table) ? getTableName(table) : 'unknown');
const tx = {
select: () => {
log.events.push('select');
return selectChain;
},
insert: (table: unknown) => ({
values: (row: Record<string, unknown>) => {
log.events.push(`insert:${name(table)}`);
log.inserted.push({ table: name(table), row });
const written = [{ id: `${name(table)}-${log.inserted.length}`, ...row }];
return {
returning: async () => written,
then: (resolve: (value: unknown) => unknown) => resolve(undefined),
};
},
}),
update: (table: unknown) => ({
set: (values: Record<string, unknown>) => ({
where: () => {
log.events.push(`update:${name(table)}`);
log.updated.push({ table: name(table), values });
return Object.assign(Promise.resolve(undefined), {
returning: async () => [{ id: `${name(table)}-updated`, ...values }],
});
},
}),
}),
};
return {
db: {
transaction: async (work: (t: unknown) => Promise<unknown>) => {
log.events.push('transaction');
return work(tx);
},
// The same scripted chain outside a transaction, so a read handler can be
// driven through the mounted routes rather than only its service.
select: tx.select,
} as unknown as Database,
log,
};
}
/** The motion routes with a principal already resolved, as `createApp` mounts them. */
function mounted(db: Database, who: Principal): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.use('*', async (c, next) => {
c.set('principal', who);
await next();
});
app.route('/', createMotionRoutes(db));
return app;
}
function rendered(userId: string, isPlatformAdmin = false) {
const predicate = visibleTemplates({ userId, isPlatformAdmin });
return predicate ? new PgDialect().sqlToQuery(predicate) : null;
}
// ------------------------------------------------------------------- the tests
describe('the private library filter is a WHERE clause, not an affordance', () => {
it('matches shared rows for everyone and private rows only against the viewer id', () => {
const query = rendered(OWNER);
assert.ok(query, 'a member must be filtered at all');
assert.match(query.sql, /"visibility" = \$1/);
assert.match(query.sql, /"owner_user_id" = \$3/);
assert.deepEqual(query.params, ['shared', 'private', OWNER]);
});
it('never widens to `owner_user_id IS NULL` — an orphaned private draft belongs to nobody', () => {
const query = rendered(OWNER);
// `ON DELETE SET NULL` on the owner column can produce a private row with
// no owner. Matching it here would publish every departed colleague's
// drafts to the whole workspace, and the query would look like a fix for
// rows that had "gone missing".
assert.ok(query);
assert.doesNotMatch(query.sql, /owner_user_id" is null/i);
});
it('is deliberately absent for a platform admin — an admin CAN read a private template', () => {
// Not a hole. An unfiltered query here is the decision: someone has to be
// able to answer "what is in this workspace" during an audit or a
// departure. If this ever starts returning a predicate, that was a choice
// somebody made, and this test is where they say so.
assert.equal(rendered(OTHER, true), null);
});
});
describe('a slug is the identity of a lineage', () => {
it('strips the combining mark NFKD leaves behind rather than hyphenating through a word', () => {
// `normalize('NFKD')` splits "é" into "e" plus a combining acute, and the
// `[^a-z0-9]+` rule that follows turns that mark into a separator — so
// without the strip, "Café strategy" starts the lineage `caf-e-strategy`
// and the next person authoring the same title cannot find it.
assert.equal(motionSlug('Café strategy'), 'cafe-strategy');
assert.equal(motionSlug('Proposal Blocks'), 'proposal-blocks');
// Nothing latin survives, and an empty slug would violate the NOT NULL.
assert.equal(motionSlug('日本語'), 'untitled');
});
});
describe('a used template is never edited in place', () => {
it('refuses a PATCH once usage_count is above zero, and names the versions endpoint', async () => {
const { db, log } = database([[template({ usageCount: 3 })]]);
await assert.rejects(
executeMutation(
db,
principal(onTeam('demand', 'member')),
async () => ({ body: '# Rewritten' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_in_use' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.updated, [], 'a live engagement must not have its template move underneath it');
});
it('edits freely while nobody has instantiated it — an unused template is still a draft', async () => {
const { db, log } = database([[template({ usageCount: 0 })]]);
await executeMutation(
db,
member,
async () => ({ title: 'POC plan, tightened' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
);
assert.equal(log.updated.length, 1);
assert.equal(log.updated[0]?.table, 'motion_templates');
assert.equal(log.updated[0]?.values.title, 'POC plan, tightened');
});
it('refuses an edit to somebody else\'s template even when it is shared — publishing is not donating', async () => {
const { db, log } = database([[template({ visibility: 'shared', ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ body: '# Mine now' }),
motionTemplateUpdateDefinition(),
{ id: TEMPLATE_ID },
),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
});
describe('publishing is a lead\'s judgement, and the owner\'s', () => {
it('refuses a member without motion:publish before the row is read at all', async () => {
const { db, log } = database([[template()]]);
await assert.rejects(
executeMutation(db, member, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.events, [], 'permission precedes the transaction, so nothing was queried');
});
it('refuses a lead flipping somebody else\'s private template to shared', async () => {
const { db, log } = database([[template({ ownerUserId: OTHER })]]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionTemplatePublishDefinition(), {
id: TEMPLATE_ID,
}),
(error: unknown) => error instanceof AuthError && error.code === 'not_owner',
);
assert.deepEqual(log.updated, []);
});
it('gates creating straight into the shared library on the same capability', async () => {
// The publish endpoint is not the only door into the shared library, so a
// member who simply POSTs `visibility: 'shared'` must be refused too.
const { db, log } = database([]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
visibility: 'shared',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
);
assert.deepEqual(log.inserted, []);
});
it('creates a private template owned by the author, because an unowned private row is unreadable', async () => {
const { db, log } = database([]);
await executeMutation(
db,
member,
async () => ({
kind: 'poc',
title: 'POC plan',
summary: 'What a POC must show.',
body: '# POC',
stage: 'poc',
}),
motionTemplateCreateDefinition(),
);
const written = log.inserted.find((row) => row.table === 'motion_templates');
assert.equal(written?.row.visibility, 'private');
assert.equal(written?.row.ownerUserId, member.userId);
assert.equal(written?.row.version, 1);
assert.equal(written?.row.slug, 'poc-plan');
});
});
describe('promotion is the loop', () => {
function promote(db: Database, input: Record<string, unknown> = {}) {
return executeMutation(
db,
lead,
async () => input,
motionArtifactPromoteDefinition(),
{ id: ARTIFACT_ID },
);
}
it('refuses to promote an artifact that is already in the library', async () => {
const { db, log } = database([[artifact({ promotedTemplateId: TEMPLATE_ID })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'already_promoted' &&
error.status === 409,
);
assert.deepEqual(log.inserted, [], 'a second promotion would fork the lineage silently');
});
it('refuses to promote a draft — the library is what the next deployment copies', async () => {
const { db, log } = database([[artifact({ status: 'draft' })]]);
await assert.rejects(
promote(db),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'artifact_not_final' &&
error.status === 409,
);
assert.deepEqual(log.inserted, []);
});
it('writes a shared version pointing back at the artifact that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { version: number; visibility: string; originArtifactId: string };
};
assert.equal(result.template.version, 1);
assert.equal(result.template.visibility, 'shared');
assert.equal(result.template.originArtifactId, ARTIFACT_ID);
assert.equal(
log.updated.find((row) => row.table === 'engagement_artifacts')?.values.promotedTemplateId,
'motion_templates-1',
);
});
it('chains supersedes_id across three promotions of one lineage', async () => {
let previous: { id: string; version: number } | null = null;
const chain: { id: string; version: number; supersedesId: string | null }[] = [];
for (let round = 0; round < 3; round += 1) {
// The lineage is read twice and on purpose: once unfiltered for the
// version number, once through the visibility filter for the row the new
// version may claim to supersede.
const lineage = previous ? [{ ...template(), ...previous, slug: 'poc-plan' }] : [];
const { db } = database([
[artifact({ id: `${ARTIFACT_ID}-${round}` })],
[{ engagement, deal }],
lineage,
lineage,
]);
const result = (await promote(db, { slug: 'poc-plan' })) as {
template: { id: string; version: number; supersedesId: string | null };
};
chain.push({
id: result.template.id,
version: result.template.version,
supersedesId: result.template.supersedesId,
});
previous = { id: result.template.id, version: result.template.version };
}
assert.deepEqual(
chain.map((row) => row.version),
[1, 2, 3],
);
assert.equal(chain[0]?.supersedesId, null, 'the first version supersedes nothing');
assert.equal(chain[1]?.supersedesId, chain[0]?.id);
assert.equal(chain[2]?.supersedesId, chain[1]?.id);
});
it('lands the audit row on the account of the deal that proved it', async () => {
const { db, log } = database([[artifact()], [{ engagement, deal }], []]);
await promote(db, { slug: 'poc-plan' });
const activity = log.inserted.find((row) => row.table === 'activities')?.row;
assert.equal(activity?.accountId, ACCOUNT_ID);
assert.equal(activity?.demandDealId, DEAL_ID);
assert.match(String(activity?.subject), /^Promoted to the library:/);
});
});
describe('a lineage nobody can read never supplies content to one everybody can', () => {
/**
* The version number and the predecessor row are two different questions, and
* answering both from one unfiltered query is the shape of the bug these pin.
* Both suites script a lineage whose newest row is private to somebody else:
* the unfiltered read finds v4, the filtered read finds nothing.
*/
const PRIVATE_SUMMARY = 'Unreleased pricing: 40% floor, Northwind only.';
/** Alice's v4, which the unfiltered read finds and the filtered read must not. */
const hidden = () =>
template({
id: '00000000-0000-4000-8000-0000000000c4',
version: 4,
ownerUserId: OTHER,
summary: PRIVATE_SUMMARY,
});
it('promotes past a private newest version without copying its summary or naming its id', async () => {
const { db, log } = database([
[artifact()],
[{ engagement, deal }],
[hidden()],
[],
]);
await executeMutation(db, lead, async () => ({ slug: 'poc-plan' }), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
// v5, because the unique constraint is on the whole lineage and a private
// fork still consumes a number.
assert.equal(written?.version, 5);
// But nothing else from that row. The summary fell back to the artifact's
// own title, and the new version supersedes nothing it cannot show.
assert.notEqual(written?.summary, PRIVATE_SUMMARY);
assert.equal(written?.summary, artifact().title);
assert.equal(written?.supersedesId, null);
});
it('forks a shared template without disclosing that private versions of it exist', async () => {
const source = template({ id: TEMPLATE_ID, visibility: 'shared', ownerUserId: OTHER });
const { db, log } = database([[source], [hidden()], []]);
await executeMutation(db, member, async () => ({}), motionTemplateVersionDefinition(), {
id: TEMPLATE_ID,
});
const written = log.inserted.find((row) => row.table === 'motion_templates')?.row;
assert.equal(written?.version, 5, 'the number is allocated against the whole lineage');
assert.equal(
written?.supersedesId,
source.id,
'the edge points at the row that was actually forked, not at a private row the forker cannot fetch',
);
});
it('refuses to promote an artifact whose source template is unreadable, rather than 404ing on the artifact', async () => {
const { db, log } = database([
[artifact({ templateId: TEMPLATE_ID })],
[{ engagement, deal }],
[],
]);
await assert.rejects(
executeMutation(db, lead, async () => ({}), motionArtifactPromoteDefinition(), {
id: ARTIFACT_ID,
}),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'source_template_unreadable' &&
error.status === 409 &&
error.message.includes('slug'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('a template id somebody sent is read before it is stored', () => {
it('refuses a playbook the setter cannot read, so a private id cannot go book-wide on an engagement', async () => {
const { db, log } = database([[{ engagement, deal }], []]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ playbookTemplateId: TEMPLATE_ID }),
motionEngagementUpdateDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.status === 404,
);
assert.deepEqual(log.updated, [], 'an unreadable id must not reach the foreign key either');
});
});
describe('a UI hint that disagrees with its endpoint is the hint that is wrong', () => {
it('does not offer publish to a read-only key, which the endpoint would refuse for scope', async () => {
const readOnlyLead = principal({ ...onTeam('demand', 'lead'), via: 'api_key', scopes: ['read'] });
const mine = template({ ownerUserId: readOnlyLead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, readOnlyLead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(response.status, 200);
assert.equal(
body.canPublish,
false,
'the button would 403 insufficient_scope, and a button that cannot work must not be offered',
);
});
it('still offers it to the same lead on a full-scope session', async () => {
const mine = template({ ownerUserId: lead.userId });
const { db } = database([[mine], [mine]]);
const response = await mounted(db, lead).request(`/api/motion/templates/${TEMPLATE_ID}`);
const body = (await response.json()) as { canPublish: boolean };
assert.equal(body.canPublish, true);
});
});
describe('a slug collision is the caller\'s to fix, not a 500', () => {
it('answers 409 naming the versions endpoint when the title derives a slug already in use', async () => {
// "Proposal Blocks" is one of the nine shipped starter templates, so this
// is the first thing a new author trips over rather than an edge case.
const { db, log } = database([[{ version: 1 }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({
kind: 'proposal',
title: 'Proposal Blocks',
summary: 'Reusable proposal language.',
body: '# Blocks',
stage: 'proposal',
}),
motionTemplateCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'template_slug_exists' &&
error.status === 409 &&
error.message.includes('/versions'),
);
assert.deepEqual(log.inserted, []);
});
});
describe('instantiating a template', () => {
it('increments usage_count in the same transaction that writes the artifact', async () => {
const { db, log } = database([
[{ engagement, deal }],
[template({ usageCount: 2, visibility: 'shared', ownerUserId: OTHER })],
]);
await executeMutation(
db,
member,
async () => ({ templateId: TEMPLATE_ID }),
motionArtifactCreateDefinition(),
{ id: ENGAGEMENT_ID },
);
// One transaction, both writes: a count that could be committed without the
// artifact would close the template to edits for a use that never happened.
assert.equal(log.updated[0]?.table, 'motion_templates');
// And the count is incremented by the database, not by JS arithmetic on the
// row that was read a moment ago. Two people instantiating one template
// under READ COMMITTED both read 2 and both write 3, so a use is lost — and
// `usage_count` is the only thing holding §7a shut. Asserting the rendered
// SQL is what makes the difference between the two visible at all: the
// wrong version writes the literal 3 and passes every behavioural test.
const increment = log.updated[0]?.values.usageCount;
assert.ok(
isSQLWrapper(increment),
'usage_count must be written as an expression, never as a number computed in JS',
);
assert.match(new PgDialect().sqlToQuery(increment as SQL).sql, /"usage_count" \+ \$?1/);
assert.equal(log.inserted[0]?.table, 'engagement_artifacts');
assert.equal(log.inserted[0]?.row.templateId, TEMPLATE_ID);
// The body is copied, not referenced — editing the artifact must not reach
// back into the library.
assert.equal(log.inserted[0]?.row.body, template().body);
});
});
describe('one engagement per deal', () => {
it('answers 409 with the id of the engagement that already exists', async () => {
const { db, log } = database([[deal], [engagement]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ demandDealId: DEAL_ID }),
motionEngagementCreateDefinition(),
),
(error: unknown) =>
error instanceof MutationError &&
error.code === 'engagement_exists' &&
error.status === 409 &&
error.message.includes(ENGAGEMENT_ID),
);
assert.deepEqual(log.inserted, []);
});
});
describe('qualification scores', () => {
it('computes the score from the dimensions rather than believing the body', async () => {
const { db, log } = database([[{ engagement, deal }]]);
const dimensions = [
{ id: 'budget', weight: 30, score: 4 },
{ id: 'urgency', weight: 20, score: 2 },
{ id: 'fit', weight: 50, score: 3 },
];
await executeMutation(
db,
member,
async () => ({ dimensions }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
);
const written = log.inserted.find((row) => row.table === 'qualification_scores')?.row;
assert.equal(written?.basisPoints, motionScoreBasisPoints(dimensions));
assert.equal(written?.band, 'Strategic');
assert.equal(typeof written?.basisPoints, 'number');
assert.ok(Number.isInteger(written?.basisPoints), 'a score is an integer, exactly as money is');
});
it('refuses a posted basisPoints outright — a score somebody can send is a score somebody can fix', async () => {
const { db } = database([[{ engagement, deal }]]);
await assert.rejects(
executeMutation(
db,
member,
async () => ({ dimensions: [{ id: 'fit', weight: 1, score: 1 }], basisPoints: 10_000 }),
motionScoreDefinition(),
{ id: ENGAGEMENT_ID },
),
(error: unknown) => error instanceof MutationError && error.code === 'invalid_request',
);
});
});
+73 -1
View File
@@ -9,7 +9,7 @@
*
* Design rules, learned from tool surfaces that went wrong:
*
* **Keep it small.** Nine tools, each doing one thing. A sprawling tool list
* **Keep it small.** Ten tools, each doing one thing. A sprawling tool list
* degrades model performance more than it adds capability; anything genuinely
* niche belongs behind `pig_search` or the HTTP API.
*
@@ -22,6 +22,7 @@
* and quote back to a human. A wall of raw JSON forces the model to re-derive
* meaning that the server already knows.
*/
import { DEMAND_STAGES, MOTION_KINDS } from '@pig/core';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
@@ -542,6 +543,77 @@ export function createPigMcpServer(options: PigMcpOptions): McpServer {
},
);
// ----------------------------------------------------------- motion library
server.registerTool(
'pig_motion_library',
{
title: 'Search the motion library',
description:
'Find reusable go-to-market practice: discovery guides, qualification frameworks, POC ' +
'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' +
'technical narratives and deployment playbooks, each bound to the demand stage it ' +
'serves. Use it before writing a proposal or scoping a POC from scratch — a template ' +
'with a usage count is language that has already survived a customer.',
inputSchema: {
kind: z
.enum(MOTION_KINDS)
.optional()
.describe('Restrict to one kind of template. Omit for all nine.'),
stage: z
.enum(DEMAND_STAGES)
.optional()
.describe('Restrict to templates serving one demand stage, e.g. proposal.'),
query: z.string().optional().describe('Matched against title, summary and slug'),
},
},
async (input) => {
const params = new URLSearchParams();
if (input.kind) params.set('kind', input.kind);
if (input.stage) params.set('stage', input.stage);
if (input.query) params.set('q', input.query);
const { templates, truncated } = await api.request<{
templates: {
id: string;
kind: string;
slug: string;
version: number;
title: string;
summary: string;
stage: string;
visibility: string;
usageCount: number;
updatedAt: string;
}[];
truncated: boolean;
}>(`/api/motion/templates?${params}`);
if (templates.length === 0) {
return ok(
'No template matches that request. Note that this key sees shared templates and ' +
"the caller's own private drafts, so a colleague's draft will not appear.",
);
}
const lines = [
`${templates.length}${truncated ? '+' : ''} template(s), newest version of each:\n`,
];
for (const t of templates.slice(0, 25)) {
lines.push(
`${t.title}${t.kind}, ${t.stage} stage, v${t.version}` +
`${t.visibility === 'private' ? ' [private draft]' : ''}`,
` ${t.summary}`,
// Usage is the difference between practice and a document nobody
// opened, and it is the field a model should rank on.
` Instantiated ${t.usageCount} time(s) · id: ${t.id}`,
'',
);
}
return ok(lines.join('\n'));
},
);
// ------------------------------------------------------------- log activity
server.registerTool(
+21
View File
@@ -22,6 +22,9 @@ export const PIGGY_PAGE_TOOL_NAMES = [
'pig_get_idle_capacity',
'pig_get_pipeline',
'pig_get_calendar_ahead',
'pig_get_motion_summary',
'pig_search_motion_library',
'pig_get_engagement',
] as const;
export type PiggyPageToolName = (typeof PIGGY_PAGE_TOOL_NAMES)[number];
@@ -97,6 +100,24 @@ const GUIDES: Partial<Record<PiggyPageRoute, PiggyPageGuide>> = {
label: 'the contracts list — Piggy reads its dates here, not its terms',
tool: 'pig_get_calendar_ahead',
},
'/motion': {
label: 'the Motion home — stage coverage, the shared library, and recent promotions',
tool: 'pig_get_motion_summary',
},
/*
* The label says "shared" because the tool reads nothing else, and the model
* is otherwise free to conclude that a template it cannot find is missing
* rather than private. A user asking "where is my draft?" should be told
* Piggy cannot see private drafts, not that no such template exists.
*/
'/motion/library': {
label: 'the motion library — Piggy reads the shared templates here, never a private draft',
tool: 'pig_search_motion_library',
},
'/motion/engagements': {
label: 'the engagement list — the demand deals with a motion running against them',
tool: 'pig_get_engagement',
},
'/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' },
'/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' },
'/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' },
+396 -1
View File
@@ -28,6 +28,8 @@
import {
CONSUMING_ALLOCATION_STATUSES,
DEMAND_OPEN_STAGES,
DEMAND_STAGES,
MOTION_KINDS,
RESERVING_ALLOCATION_STATUSES,
SUPPLY_OPEN_STAGES,
aggregateMargin,
@@ -37,19 +39,27 @@ import {
type AllocationInput,
type CalendarEvent,
type CalendarEventKind,
type DemandStage,
type MarginResult,
type MotionKind,
type PiggyPageRoute,
} from '@pig/core';
import {
accounts,
allocations,
capacityCommitments,
demandDeals,
engagementArtifacts,
engagements,
motionTemplates,
qualificationScores,
supplyDeals,
type Database,
} from '@pig/db';
import { CalendarService } from '@pig/api/src/services/calendar';
import { and, gte, inArray, isNull } from 'drizzle-orm';
import { and, desc, eq, gte, ilike, inArray, isNotNull, isNull, or, type SQL } from 'drizzle-orm';
import { z } from 'zod';
import { likeFragment } from './chat-tools';
import { piggyPageGuide, type PiggyPageToolName } from './page-routes';
import { defineTool, type AgentTool } from './provider';
@@ -161,6 +171,72 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool {
.strict(),
execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30),
});
case 'pig_get_motion_summary':
return defineTool({
name,
description:
'Read whether the go-to-market motion is repeating: which demand stages the shared ' +
'library covers and which it does not, how many live engagements sit at each stage, ' +
'the shared library by kind, and the artifacts most recently promoted back into it. ' +
'Private drafts are not visible to this tool.',
inputSchema: noInput,
execute: async () => readMotionSummary(db),
});
case 'pig_search_motion_library':
return defineTool({
name,
description:
'Search the shared motion library — discovery guides, qualification frameworks, POC ' +
'structures, proposal blocks, pricing inputs, reference architectures, case studies, ' +
'technical narratives and deployment playbooks. Returns the newest version of each ' +
'template. Private drafts are never searched, whoever is asking.',
inputSchema: z
.object({
kind: z
.enum(MOTION_KINDS)
.describe('Restrict to one kind of template. null for every kind.')
.nullish(),
stage: z
.enum(DEMAND_STAGES)
.describe('Restrict to templates serving one demand stage. null for every stage.')
.nullish(),
query: z
.string()
.trim()
.min(2)
.max(64)
.describe(
'Word or phrase matched against the title, summary and slug. null returns the ' +
'whole shared library.',
)
.nullish(),
})
.strict(),
execute: async (filter) => readMotionLibrary(db, filter),
});
case 'pig_get_engagement':
return defineTool({
name,
description:
'Read the engagements running against demand deals: which stage the deal sits at, ' +
'how many artifacts have been produced and how many are final, and the latest ' +
'qualification score with its band.',
inputSchema: z
.object({
query: z
.string()
.trim()
.min(2)
.max(64)
.describe(
'Word or phrase matched against the deal name and the engagement summary. ' +
'null returns the most recently opened engagements.',
)
.nullish(),
})
.strict(),
execute: async ({ query }) => readEngagements(db, query ?? null),
});
case 'pig_get_workspace_summary':
return defineTool({
name,
@@ -585,6 +661,325 @@ function countByState(events: readonly CalendarEvent[]): Record<string, number>
return counts;
}
// ---------------------------------------------------------------------------
// The motion
// ---------------------------------------------------------------------------
export interface MotionLibraryFilter {
kind?: MotionKind | null;
stage?: DemandStage | null;
query?: string | null;
}
/**
* Shared templates only, unconditionally not "unless the asker owns it".
*
* Motion is the one place in PIG with a row-level access rule: a `private`
* template is readable by its owner and by a platform admin, and by nobody
* else. Piggy has no reliable notion of who is asking. The dock publishes a
* route, the relay checks a capability, and the tool then runs as the process;
* nothing reaches this query that identifies a person strongly enough to widen
* it on. So it is not widened. A model that can be argued into reading a
* colleague's private draft is a leak with the extra step of asking politely,
* and the argument would arrive as ordinary conversation the guard never sees.
*
* This is the only clause here that must not become a parameter. If private
* drafts ever need an answer, the identity has to arrive with the request and
* be enforced in `services/motion.ts` where the API already enforces it not
* by relaxing this. `motion-tools.test.ts` fails if it is.
*/
export function motionLibraryWhere(filter: MotionLibraryFilter): SQL {
const conditions: SQL[] = [
eq(motionTemplates.visibility, 'shared'),
isNull(motionTemplates.archivedAt),
];
if (filter.kind) conditions.push(eq(motionTemplates.kind, filter.kind));
if (filter.stage) conditions.push(eq(motionTemplates.stage, filter.stage));
if (filter.query) {
const fragment = likeFragment(filter.query);
conditions.push(
or(
ilike(motionTemplates.title, fragment),
ilike(motionTemplates.summary, fragment),
ilike(motionTemplates.slug, fragment),
)!,
);
}
return and(...conditions)!;
}
interface LineageRow {
slug: string;
kind: MotionKind;
stage: DemandStage;
version: number;
title: string;
summary: string;
usageCount: number;
}
/**
* One row per lineage, newest version winning.
*
* The library counts lineages rather than rows because a template promoted
* three times is one piece of practice with a history, and counting its
* versions reports a library four times the size of the one anybody can choose
* from. The slug is the identity of the lineage see the schema header.
*/
function newestPerSlug<Row extends { slug: string; version: number }>(rows: readonly Row[]): Row[] {
const newest = new Map<string, Row>();
for (const row of rows) {
const held = newest.get(row.slug);
if (!held || row.version > held.version) newest.set(row.slug, row);
}
return [...newest.values()];
}
function countBy<Row>(rows: readonly Row[], key: (row: Row) => string): Record<string, number> {
const counts: Record<string, number> = {};
for (const row of rows) counts[key(row)] = (counts[key(row)] ?? 0) + 1;
return counts;
}
/**
* The question the /motion page exists to answer: is the motion repeating?
*
* Coverage is reported as the stages the shared library does NOT reach, not
* only as a count, because "6 of 8 stages covered" is a figure nobody acts on
* and "nothing covers procurement or deployment" is a piece of work. The two
* closed stages are excluded throughout a won deal has left the motion.
*/
async function readMotionSummary(db: Database): Promise<unknown> {
const [libraryRead, engagementRead, promotions] = await Promise.all([
db
.select({
slug: motionTemplates.slug,
kind: motionTemplates.kind,
stage: motionTemplates.stage,
version: motionTemplates.version,
title: motionTemplates.title,
summary: motionTemplates.summary,
usageCount: motionTemplates.usageCount,
})
.from(motionTemplates)
.where(motionLibraryWhere({}))
.limit(SCAN_LIMIT + 1),
db
.select({ stage: demandDeals.stage, dealName: demandDeals.name })
.from(engagements)
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
.where(eq(engagements.status, 'open'))
.limit(SCAN_LIMIT + 1),
db
.select({
title: motionTemplates.title,
kind: motionTemplates.kind,
version: motionTemplates.version,
createdAt: motionTemplates.createdAt,
})
.from(motionTemplates)
.where(and(motionLibraryWhere({}), isNotNull(motionTemplates.originArtifactId)))
.orderBy(desc(motionTemplates.createdAt))
.limit(EXEMPLARS),
]);
const { rows: templateRows, truncated: libraryTruncated } = bounded(libraryRead);
const { rows: openEngagements, truncated: engagementsTruncated } = bounded(engagementRead);
const truncated = libraryTruncated || engagementsTruncated;
const lineages = newestPerSlug(templateRows);
const engagementsByStage = countBy(openEngagements, (row) => row.stage);
const uncovered = DEMAND_OPEN_STAGES.filter(
(stage) => !lineages.some((template) => template.stage === stage),
);
return {
headline:
`${atLeast(lineages.length, libraryTruncated)} shared template(s) across ` +
`${Object.keys(countBy(lineages, (row) => row.kind)).length} of ${MOTION_KINDS.length} ` +
`kind(s), and ${atLeast(openEngagements.length, engagementsTruncated)} open engagement(s). ` +
(uncovered.length === 0
? 'Every live demand stage has at least one shared template.'
: `No shared template covers ${uncovered.join(', ')}.`) +
` ${promotions.length} artifact(s) promoted back into the library recently.` +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
sharedTemplates: lineages.length,
openEngagements: openEngagements.length,
uncoveredStages: uncovered,
stages: DEMAND_OPEN_STAGES.map((stage) => ({
stage,
openEngagements: engagementsByStage[stage] ?? 0,
sharedTemplates: lineages.filter((template) => template.stage === stage).length,
})),
libraryByKind: countBy(lineages, (row) => row.kind),
// The loop made visible: what the last few engagements gave back.
recentPromotions: promotions.map((row) => ({
title: row.title,
kind: row.kind,
version: row.version,
promotedAt: row.createdAt.toISOString(),
})),
};
}
async function readMotionLibrary(db: Database, filter: MotionLibraryFilter): Promise<unknown> {
const { rows, truncated } = bounded(
await db
.select({
slug: motionTemplates.slug,
kind: motionTemplates.kind,
stage: motionTemplates.stage,
version: motionTemplates.version,
title: motionTemplates.title,
summary: motionTemplates.summary,
usageCount: motionTemplates.usageCount,
})
.from(motionTemplates)
.where(motionLibraryWhere(filter))
.orderBy(desc(motionTemplates.updatedAt))
.limit(SCAN_LIMIT + 1),
);
const lineages = newestPerSlug(rows as LineageRow[]);
const described = [
filter.kind ? `kind ${filter.kind}` : null,
filter.stage ? `stage ${filter.stage}` : null,
filter.query ? `"${filter.query}"` : null,
].filter((part): part is string => part !== null);
const scope = described.length ? ` matching ${described.join(', ')}` : '';
return {
headline:
(lineages.length === 0
? `No shared template${scope}. Private drafts are not searched, so a template may ` +
'exist and not be visible here.'
: `${atLeast(lineages.length, truncated)} shared template(s)${scope}, newest version ` +
'of each.') + (truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
count: lineages.length,
byKind: countBy(lineages, (row) => row.kind),
// Most recently updated first: the practice people are actually amending.
templates: lineages.slice(0, EXEMPLARS).map((template) => ({
slug: template.slug,
kind: template.kind,
stage: template.stage,
version: template.version,
title: template.title,
summary: template.summary,
// How many engagements have instantiated it — the only evidence here of
// whether a template is practice or an unread document.
usageCount: template.usageCount,
})),
};
}
/**
* Engagements, with the two figures anyone asks for: how much has been produced
* and where qualification landed.
*
* Artifacts and scores are read only for the exemplars, so the second and third
* queries stay small however wide the book is. The engagement's playbook
* template is deliberately not joined: it may be a private draft, and naming it
* would walk round the library rule by another door.
*/
async function readEngagements(db: Database, query: string | null): Promise<unknown> {
const fragment = query ? likeFragment(query) : null;
const { rows, truncated } = bounded(
await db
.select({
id: engagements.id,
status: engagements.status,
summary: engagements.summary,
openedAt: engagements.openedAt,
stage: demandDeals.stage,
dealName: demandDeals.name,
accountName: accounts.name,
})
.from(engagements)
.innerJoin(demandDeals, eq(engagements.demandDealId, demandDeals.id))
.leftJoin(accounts, eq(demandDeals.accountId, accounts.id))
.where(
fragment
? or(ilike(demandDeals.name, fragment), ilike(engagements.summary, fragment))
: undefined,
)
.orderBy(desc(engagements.openedAt))
.limit(SCAN_LIMIT + 1),
);
const exemplars = rows.slice(0, EXEMPLARS);
const ids = exemplars.map((row) => row.id);
const [artifacts, scores] = ids.length
? await Promise.all([
db
.select({ engagementId: engagementArtifacts.engagementId, status: engagementArtifacts.status })
.from(engagementArtifacts)
.where(
and(
inArray(engagementArtifacts.engagementId, ids),
isNull(engagementArtifacts.archivedAt),
),
)
.limit(SCAN_LIMIT),
db
.select({
engagementId: qualificationScores.engagementId,
basisPoints: qualificationScores.basisPoints,
band: qualificationScores.band,
scoredAt: qualificationScores.scoredAt,
})
.from(qualificationScores)
.where(inArray(qualificationScores.engagementId, ids))
.orderBy(desc(qualificationScores.scoredAt))
.limit(SCAN_LIMIT),
])
: [[], []];
// Newest first out of the query, so the first score seen for an engagement is
// its latest; a later one must not overwrite it.
const latestScore = new Map<string, (typeof scores)[number]>();
for (const score of scores) if (!latestScore.has(score.engagementId)) latestScore.set(score.engagementId, score);
return {
headline:
(rows.length === 0
? query
? `No engagement matches "${query}".`
: 'No demand deal has an engagement running against it yet.'
: `${atLeast(rows.length, truncated)} engagement(s)${query ? ` matching "${query}"` : ''}, ` +
`of which ${rows.filter((row) => row.status === 'open').length} open.`) +
(truncated ? ` ${TRUNCATION_NOTE}` : ''),
truncated,
count: rows.length,
byStatus: countBy(rows, (row) => row.status),
byStage: countBy(rows, (row) => row.stage),
engagements: exemplars.map((row) => {
const mine = artifacts.filter((artifact) => artifact.engagementId === row.id);
const score = latestScore.get(row.id);
return {
dealName: row.dealName,
accountName: row.accountName,
stage: row.stage,
status: row.status,
summary: row.summary,
openedAt: row.openedAt.toISOString(),
artifacts: mine.length,
finalArtifacts: mine.filter((artifact) => artifact.status === 'final').length,
latestScore: score
? {
// Basis points of the maximum, so a tenth of a per cent — the
// band is the part a seller acts on.
basisPoints: score.basisPoints,
percent: `${(score.basisPoints / 100).toFixed(1)}%`,
band: score.band,
scoredAt: score.scoredAt.toISOString(),
}
: null,
};
}),
};
}
// ---------------------------------------------------------------------------
// The fallback
// ---------------------------------------------------------------------------
+211
View File
@@ -0,0 +1,211 @@
/**
* The motion page tools, and the one rule none of them may relax.
*
* Motion is the first feature in PIG with a row-level access rule: a `private`
* template belongs to its owner and to a platform admin, and to nobody else.
* Every other read in this process is book-wide, so the habit of the codebase
* is against this clause rather than for it which is exactly why it is pinned
* here rather than left to a review.
*
* Piggy cannot enforce ownership because it does not reliably know who is
* asking: the dock publishes a route and the relay checks a capability, and
* neither reaches the query. So the query is closed instead. The tests below
* assert the closed form under every filter combination a model can send,
* because the plausible-but-wrong version of this code is one where the clause
* is present in the unfiltered read and lost in a branch.
*
* The unit suite runs in CI BEFORE the migration step, against a database with
* no tables, so nothing here may execute a query. The WHERE clause is rendered
* with `PgDialect` rather than run.
*/
import assert from 'node:assert/strict';
import test from 'node:test';
import { MOTION_KINDS } from '@pig/core';
import type { Database } from '@pig/db';
import { PgDialect } from 'drizzle-orm/pg-core';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { assertPigToolBoundary } from '../src/chat';
import { createPagePigTools, motionLibraryWhere } from '../src/page-tools';
/** Schema and SQL-shape checks only: no query is executed. */
const db = {} as Database;
const dialect = new PgDialect();
function renderedWhere(filter: Parameters<typeof motionLibraryWhere>[0]): {
text: string;
params: unknown[];
} {
const query = dialect.sqlToQuery(motionLibraryWhere(filter));
return { text: query.sql, params: query.params };
}
function tool(route: '/motion' | '/motion/library' | '/motion/engagements') {
const [only, ...rest] = createPagePigTools(db, route);
assert.ok(only, `${route} has a tool`);
// One tool per page: a second is a second thing to choose wrongly, and a
// wrong choice costs one of four turns.
assert.equal(rest.length, 0);
return only;
}
// ---------------------------------------------------------------------------
// The visibility invariant. Do not relax this to "unless the user owns it".
// ---------------------------------------------------------------------------
test('the library query filters to shared templates UNCONDITIONALLY, under every filter', () => {
const filters: Parameters<typeof motionLibraryWhere>[0][] = [
{},
{ kind: 'proposal' },
{ stage: 'poc' },
{ query: 'sovereign' },
{ kind: 'playbook', stage: 'deployment', query: 'inference' },
// The nulls a schema-abiding model sends for "no filter" must not read as
// "no visibility filter either".
{ kind: null, stage: null, query: null },
];
for (const filter of filters) {
const { params } = renderedWhere(filter);
assert.ok(
params.includes('shared'),
`visibility = 'shared' is missing for ${JSON.stringify(filter)}`,
);
// Bound to the column, not merely present somewhere in the statement.
assert.match(renderedWhere(filter).text, /"visibility" = \$\d+/);
}
});
test('no filter a model can send widens the library beyond shared — private is not a parameter', () => {
const library = tool('/motion/library');
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
// `.strict()`, so every one of these is refused rather than ignored. A tool
// that silently drops an unknown key teaches a model to keep trying.
assert.equal(accepts({ visibility: 'private' }), false);
assert.equal(accepts({ ownerUserId: '20000000-0000-4000-8000-000000000002' }), false);
assert.equal(accepts({ includePrivate: true }), false);
assert.equal(accepts({ all: '1' }), false);
assert.equal(accepts({ kind: 'proposal' }), true);
});
test('a private template is invisible even when its title is the search term', () => {
// The query filter is an AND alongside the visibility clause, never an OR
// beside it: an `or(...)` at the top level would make any matching title
// satisfy the whole WHERE and return the private row.
const { text, params } = renderedWhere({ query: 'Halcyon' });
const [visibility] = text.split('and');
assert.ok(visibility?.includes('"visibility"'), text);
assert.ok(text.startsWith('('), text);
assert.match(text, /^\("[a-z_]+"\."visibility" = \$1 and /);
assert.equal(params[0], 'shared');
});
test('archived templates are excluded from every library read', () => {
// Archiving is how deletion works here, so a query that ignores it hands the
// model practice somebody deliberately withdrew.
assert.match(renderedWhere({}).text, /"archived_at" is null/);
assert.match(renderedWhere({ kind: 'case_study' }).text, /"archived_at" is null/);
});
test('LIKE wildcards in the model-supplied query are escaped, not honoured', () => {
// Unescaped, `%` matches every shared template and the model is handed the
// first eight as though they answered the question.
assert.deepEqual(renderedWhere({ query: '%' }).params, ['shared', '%\\%%', '%\\%%', '%\\%%']);
});
// ---------------------------------------------------------------------------
// The boundary
// ---------------------------------------------------------------------------
test('every motion page tool sits inside the PIG tool boundary', () => {
const tools = [tool('/motion'), tool('/motion/library'), tool('/motion/engagements')];
assert.deepEqual(tools.map((entry) => entry.name), [
'pig_get_motion_summary',
'pig_search_motion_library',
'pig_get_engagement',
]);
// The assertion the chat provider runs on every request: a name that fails it
// takes the whole conversation down rather than one tool.
assert.doesNotThrow(() => assertPigToolBoundary(tools));
for (const entry of tools) {
assert.ok(entry.description.length > 40, `${entry.name} has a usable description`);
}
});
test('the library and engagement tools say out loud that private drafts are not read', () => {
// The description is the only place the model learns the limit, and "I found
// nothing" is a materially different answer from "I cannot see private
// drafts" to someone looking at their own.
assert.match(tool('/motion/library').description, /[Pp]rivate drafts are never searched/);
assert.match(tool('/motion').description, /[Pp]rivate drafts are not visible/);
});
// ---------------------------------------------------------------------------
// The input bounds
// ---------------------------------------------------------------------------
test('the motion summary takes no input at all', () => {
const summary = tool('/motion');
assert.equal(summary.inputSchema.safeParse({}).success, true);
assert.equal(summary.inputSchema.safeParse({ stage: 'poc' }).success, false);
});
test('the library filters accept only ontology values, and a bounded query', () => {
const library = tool('/motion/library');
const accepts = (input: unknown) => library.inputSchema.safeParse(input).success;
for (const kind of MOTION_KINDS) assert.equal(accepts({ kind }), true);
// A kind the model invented reaches the database as a cast error rather than
// a miss, so it is refused at the schema.
assert.equal(accepts({ kind: 'battlecard' }), false);
assert.equal(accepts({ stage: 'closed_won' }), true);
assert.equal(accepts({ stage: 'negotiation' }), false);
// Trimmed before the length check, so trailing whitespace cannot smuggle a
// one-character query past the floor and match the whole library.
assert.equal(accepts({ query: ' a ' }), false);
assert.equal(accepts({ query: 'x'.repeat(64) }), true);
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
assert.equal(accepts({ query: 'x'.repeat(4000) }), false);
});
test('the engagement query is bounded and refuses anything it does not name', () => {
const engagement = tool('/motion/engagements');
const accepts = (input: unknown) => engagement.inputSchema.safeParse(input).success;
assert.equal(accepts({}), true);
assert.equal(accepts({ query: 'Halcyon' }), true);
assert.equal(accepts({ query: 'a' }), false);
assert.equal(accepts({ query: 'x'.repeat(65) }), false);
assert.equal(accepts({ engagementId: '20000000-0000-4000-8000-000000000002' }), false);
assert.equal(accepts({ limit: 500 }), false);
});
/**
* What the model is actually sent, rather than what the zod reads like.
*
* `zodToJsonSchema(..., { target: 'openAi' })` the exact call both inference
* paths make emits an optional field as REQUIRED and nullable, so a
* schema-abiding model sends `null` for every filter it does not want and
* `.optional()` would reject the call. A `.describe()` applied after the
* wrapper is dropped from the emitted schema entirely.
*/
test('an omitted motion filter arrives as the null the emitted schema asks for', () => {
const library = tool('/motion/library');
assert.equal(
library.inputSchema.safeParse({ kind: null, stage: null, query: null }).success,
true,
);
assert.equal(tool('/motion/engagements').inputSchema.safeParse({ query: null }).success, true);
const emitted = zodToJsonSchema(library.inputSchema, {
$refStrategy: 'none',
target: 'openAi',
}) as { properties?: Record<string, { description?: string }>; required?: string[] };
assert.deepEqual(emitted.required, ['kind', 'stage', 'query']);
for (const [parameter, shape] of Object.entries(emitted.properties ?? {})) {
assert.ok(
shape.description && shape.description.length > 10,
`${parameter} reaches the model with no description`,
);
}
});
+2
View File
@@ -38,7 +38,9 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.85.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.1.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.8",
"streamdown": "^2.5.0",
"tailwind-merge": "^2.6.0",
+18
View File
@@ -36,6 +36,14 @@ const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default:
const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth })));
const Calendar = lazy(() => import('@/pages/Calendar').then(({ Calendar }) => ({ default: Calendar })));
const Learn = lazy(() => import('@/pages/Learn').then(({ Learn }) => ({ default: Learn })));
// Lazy is load-bearing for these five and not merely conventional: they are the
// only pages that pull react-markdown and remark-gfm, and an eager import would
// put a markdown parser into the entry chunk every route pays for.
const Motion = lazy(() => import('@/pages/Motion').then(({ Motion }) => ({ default: Motion })));
const MotionLibrary = lazy(() => import('@/pages/MotionLibrary').then(({ MotionLibrary }) => ({ default: MotionLibrary })));
const MotionTemplate = lazy(() => import('@/pages/MotionTemplate').then(({ MotionTemplate }) => ({ default: MotionTemplate })));
const MotionEngagements = lazy(() => import('@/pages/MotionEngagements').then(({ MotionEngagements }) => ({ default: MotionEngagements })));
const Engagement = lazy(() => import('@/pages/Engagement').then(({ Engagement }) => ({ default: Engagement })));
const queryClient = new QueryClient({
defaultOptions: {
@@ -248,6 +256,16 @@ function AppRoutes() {
the next four record routes will read.
*/}
<Route path="accounts/:id" element={<RoutePage><Account /></RoutePage>} />
{/*
Flat, in the register of the routes above nesting these under a
layout route would give Motion a chrome no other group has, and the
five pages share no shell of their own.
*/}
<Route path="motion" element={<RoutePage><Motion /></RoutePage>} />
<Route path="motion/library" element={<RoutePage><MotionLibrary /></RoutePage>} />
<Route path="motion/library/:id" element={<RoutePage><MotionTemplate /></RoutePage>} />
<Route path="motion/engagements" element={<RoutePage><MotionEngagements /></RoutePage>} />
<Route path="motion/engagements/:id" element={<RoutePage><Engagement /></RoutePage>} />
<Route path="contracts" element={<RoutePage><Contracts /></RoutePage>} />
<Route path="imports" element={<RoutePage><Imports /></RoutePage>} />
<Route path="piggy" element={<RoutePage><Piggy /></RoutePage>} />
@@ -0,0 +1,75 @@
/**
* Source with a copy button the fence chrome, in one place.
*
* Reference architectures arrive as `mermaid`, both in a template's structured
* `fields` and inside the markdown body a promoted artefact brings with it, and
* neither is rendered as a picture. Bundling mermaid means roughly two
* megabytes and evaluating author-supplied text, and the proxy allows exactly
* one inline script by hash (AGENTS.md §5), so it would drag a CSP change onto
* the deployment host too. Source with a copy button gets the reader into their
* own diagram tool in two clicks; the picture is tracked as follow-up in
* `docs/motion.md`.
*
* The copy button is here rather than in each caller because a diagram in a
* body and a diagram in a field are the same thing to the person reading it,
* and one of the two silently lacking the button is the sort of difference
* nobody reports.
*/
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui';
export function CodeBlock({
source,
language,
label,
}: {
source: string;
/** The fence's language, shown as the block's caption. Absent for a bare fence. */
language?: string | null;
/** What the copy button says it copies, for a screen reader. */
label?: string;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
// `navigator.clipboard` is absent outside a secure context, which the LAN
// dev server is — so this branch is reached routinely, not exceptionally.
if (!navigator.clipboard) {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
return;
}
try {
await navigator.clipboard.writeText(source);
setCopied(true);
toast.success('Source copied.');
window.setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('The browser refused clipboard access. Select the source and copy it by hand.');
}
};
return (
<div className="min-w-0 overflow-hidden rounded-lg border border-border bg-surface-2">
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-border pl-3">
<span className="min-w-0 truncate font-mono text-[11px] lowercase text-muted">
{language ?? 'source'}
</span>
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void copy()}
aria-label={label ? `Copy the ${label} source` : 'Copy the source'}
>
{copied ? <Check className="size-4" aria-hidden /> : <Copy className="size-4" aria-hidden />}
{copied ? 'Copied' : 'Copy'}
</Button>
</div>
<pre className="scroll-x p-3 text-xs leading-5">
<code className="font-mono">{source}</code>
</pre>
</div>
);
}
@@ -0,0 +1,637 @@
/**
* The structured half of a template, rendered per kind.
*
* `fields` is authored JSON. It is not validated on the way out of the
* database, it is edited by anyone with `motion:write`, it survives promotion
* from an engagement artefact unchanged, and it will outlive whatever shape
* this file expects today. So every access below is narrowed, every list is
* filtered to the entries that carry the property being rendered, and a shape
* this file does not recognise renders as nothing at all.
*
* That last rule is the important one. The alternative assume the shape and
* let the page throw turns one badly-typed seed row into a blank library for
* everybody, and the crash surfaces in a route far from the row that caused it.
* An omitted section is a bug someone reports; a white screen is an outage.
*
* A qualification framework's dimensions are shown with their weights and
* their 04 anchors, because the anchors are what stop a score being a vibe:
* the number only means something if two people reading the same evidence pick
* the same one.
*/
import { type ReactNode } from 'react';
import { Skull, TriangleAlert } from 'lucide-react';
import { DEMAND_STAGE_LABELS, type DemandStage, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
import { anchorScale, asRecord, number, recordList, text, textList } from './fields';
export function FieldsView({
kind,
fields,
className,
}: {
kind: MotionKind;
/** Untrusted. `unknown` on purpose — see the header. */
fields: unknown;
className?: string;
}) {
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>;
}
function renderKind(kind: MotionKind, fields: Record<string, unknown>): ReactNode {
switch (kind) {
case 'discovery':
return <DiscoveryFields fields={fields} />;
case 'qualification':
return <QualificationFields fields={fields} />;
case 'poc':
return <PocFields fields={fields} />;
case 'proposal':
return <ProposalFields fields={fields} />;
case 'pricing':
return <PricingFields fields={fields} />;
case 'architecture':
return <ArchitectureFields fields={fields} />;
case 'case_study':
return <CaseStudyFields fields={fields} />;
case 'narrative':
return <NarrativeFields fields={fields} />;
case 'playbook':
return <PlaybookFields fields={fields} />;
}
}
// ------------------------------------------------------------------ the kinds
function DiscoveryFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
if (sections.length === 0) return null;
return (
<>
{sections.map((section, index) => {
const name = text(section.name);
if (!name) return null;
const questions = recordList(section.questions);
return (
<Section key={index} title={name}>
<Note label="Goal" value={text(section.goal)} />
<Note label="A bad answer" value={text(section.badAnswer)} tone="warning" />
{questions.length === 0 ? null : (
<ol className="min-w-0 space-y-3">
{questions.map((question, questionIndex) => {
const asked = text(question.q);
if (!asked) return null;
return (
<li key={questionIndex} className="min-w-0 rounded-lg border border-border p-3">
<p className="min-w-0 break-words font-medium leading-6">{asked}</p>
<Note label="Why" value={text(question.why)} />
<Note label="Listen for" value={text(question.listenFor)} />
</li>
);
})}
</ol>
)}
</Section>
);
})}
</>
);
}
function QualificationFields({ fields }: { fields: Record<string, unknown> }) {
const dimensions = recordList(fields.dimensions);
const bands = recordList(fields.bands);
const disqualifiers = recordList(fields.disqualifiers);
if (dimensions.length === 0 && bands.length === 0 && disqualifiers.length === 0) return null;
const weightTotal = dimensions.reduce((total, dimension) => total + (number(dimension.weight) ?? 0), 0);
return (
<>
{dimensions.length === 0 ? null : (
<Section
title="Dimensions"
// Weights need not sum to 100 — the score is a proportion of the
// maximum — so the total is shown rather than assumed.
aside={weightTotal > 0 ? `${weightTotal} weight in total` : undefined}
>
<div className="min-w-0 space-y-3">
{dimensions.map((dimension, index) => {
const name = text(dimension.name);
if (!name) return null;
const weight = number(dimension.weight);
const group = text(dimension.group);
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">{name}</span>
{group ? <Badge tone="neutral">{group}</Badge> : null}
{weight === null ? null : (
<span className="nums whitespace-nowrap text-xs text-muted">weight {weight}</span>
)}
</div>
<Note label="Why it predicts" value={text(dimension.why)} />
<Anchors anchors={dimension.anchors} />
</div>
);
})}
</div>
</Section>
)}
{bands.length === 0 ? null : (
<Section title="What each score means">
<ul className="min-w-0 space-y-2">
{bands.map((band, index) => {
const label = text(band.label);
if (!label) return null;
const min = number(band.min);
const max = number(band.max);
return (
<li key={index} className="min-w-0">
<span className="font-medium">{label}</span>
{min === null || max === null ? null : (
<span className="nums ml-2 text-xs text-muted">
{min}{max}
</span>
)}
<Note label="Action" value={text(band.action)} />
</li>
);
})}
</ul>
</Section>
)}
{disqualifiers.length === 0 ? null : (
<Section title="Disqualifiers" icon={<Skull className="size-4" aria-hidden />}>
<ul className="min-w-0 space-y-3">
{disqualifiers.map((disqualifier, index) => {
const name = text(disqualifier.name);
if (!name) return null;
return (
<li key={index} className="min-w-0">
<p className="min-w-0 break-words font-medium leading-6">{name}</p>
<Note label="Test" value={text(disqualifier.test)} />
<Note label="Why" value={text(disqualifier.why)} />
</li>
);
})}
</ul>
</Section>
)}
</>
);
}
/** The 04 scale, extracted by the same rule the scorer reads it with. */
function Anchors({ anchors }: { anchors: unknown }) {
const rows = anchorScale(anchors);
if (rows.length === 0) return null;
return (
<dl className="mt-3 min-w-0 space-y-1.5 border-t border-border pt-3">
{rows.map((row) => (
<div key={row.score} className="flex min-w-0 gap-3">
<dt className="nums w-5 shrink-0 text-sm font-semibold text-muted">{row.score}</dt>
<dd className="min-w-0 break-words text-sm leading-6">{row.anchor}</dd>
</div>
))}
</dl>
);
}
function PocFields({ fields }: { fields: Record<string, unknown> }) {
const hypothesis = text(fields.hypothesis);
const milestones = recordList(fields.milestones);
const metrics = recordList(fields.successMetrics);
const risks = recordList(fields.risks);
if (!hypothesis && milestones.length === 0 && metrics.length === 0 && risks.length === 0) return null;
return (
<>
{hypothesis ? (
<Section title="Hypothesis">
<p className="min-w-0 break-words leading-6">{hypothesis}</p>
</Section>
) : null}
{milestones.length === 0 ? null : (
<Section title="Milestones">
<ol className="min-w-0 space-y-3">
{milestones.map((milestone, index) => {
const title = text(milestone.title);
if (!title) return null;
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">
{text(milestone.week) ? (
<Badge tone="neutral">{text(milestone.week)}</Badge>
) : null}
<span className="min-w-0 break-words font-medium leading-6">{title}</span>
{/* A kill gate is the only reason a POC ends early rather
than drifting into a second quarter, so it is the one
property here that earns a colour. */}
{milestone.killGate === true ? <Badge tone="danger">Kill gate</Badge> : null}
</div>
<Note label="Exit criterion" value={text(milestone.exitCriterion)} />
<Note label="Owner" value={text(milestone.owner)} />
</li>
);
})}
</ol>
</Section>
)}
{metrics.length === 0 ? null : (
<Section title="Success metrics">
<ScrollTable
head={['Metric', 'Baseline', 'Target', 'Measured by']}
rows={metrics.map((metric) => [
text(metric.metric),
text(metric.baseline),
text(metric.target),
text(metric.measuredBy),
])}
/>
</Section>
)}
{risks.length === 0 ? null : (
<Section title="Risks" icon={<TriangleAlert className="size-4" aria-hidden />}>
<ScrollTable
head={['Risk', 'Owner', 'Mitigation']}
rows={risks.map((risk) => [text(risk.risk), text(risk.owner), text(risk.mitigation)])}
/>
</Section>
)}
</>
);
}
function ProposalFields({ fields }: { fields: Record<string, unknown> }) {
const blocks = recordList(fields.blocks);
if (blocks.length === 0) return null;
return (
<>
{blocks.map((block, index) => {
const title = text(block.title);
if (!title) return null;
const body = text(block.text);
return (
<Section key={index} title={title}>
<Note label="Use when" value={text(block.useWhen)} />
<Note label="Avoid when" value={text(block.avoidWhen)} tone="warning" />
{body ? (
// Proposal blocks are lifted verbatim into a document, so the
// whitespace the author wrote is part of the block.
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
</Section>
);
})}
</>
);
}
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;
return (
<>
{inputs.length === 0 ? null : (
<Section title="Inputs">
<div className="min-w-0 space-y-3">
{inputs.map((input, index) => {
const name = text(input.name);
if (!name) return null;
const unit = text(input.unit);
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">{name}</span>
{unit ? <Badge tone="neutral">{unit}</Badge> : null}
</div>
<Note label="Where it comes from" value={text(input.howToGet)} />
<Note label="Why it matters" value={text(input.whyItMatters)} />
</div>
);
})}
</div>
</Section>
)}
{packages.length === 0 ? null : (
<Section title="Packages">
<ScrollTable
head={['Package', 'Shape', 'Fits when', 'Fails when']}
rows={packages.map((entry) => [
text(entry.name),
text(entry.shape),
text(entry.fitsWhen),
text(entry.failsWhen),
])}
/>
</Section>
)}
{tradeables.length === 0 ? null : (
<Section title="What to trade">
<ScrollTable
head={['Give', 'Get']}
rows={tradeables.map((entry) => [text(entry.give), text(entry.get)])}
/>
</Section>
)}
</>
);
}
function ArchitectureFields({ fields }: { fields: Record<string, unknown> }) {
const architectures = recordList(fields.architectures);
if (architectures.length === 0) return null;
return (
<>
{architectures.map((architecture, index) => {
const name = text(architecture.name);
if (!name) return null;
const components = recordList(architecture.components);
return (
<Section key={index} title={name}>
<Note label="Fits when" value={text(architecture.fitsWhen)} />
<Note label="Fails when" value={text(architecture.failureMode)} tone="warning" />
<Diagram source={text(architecture.mermaid)} name={name} />
{components.length === 0 ? null : (
<ScrollTable
head={['Component', 'Run by', 'Why']}
rows={components.map((component) => [
text(component.component),
text(component.runBy),
text(component.why),
])}
/>
)}
</Section>
);
})}
</>
);
}
/**
* The diagram, as source rather than as a picture deliberately, for now. See
* `CodeBlock` for why, and for the copy button that makes it usable.
*/
function Diagram({ source, name }: { source: string | null; name: string }) {
if (!source) return null;
return <CodeBlock source={source} language="mermaid" label={`${name} diagram`} />;
}
function CaseStudyFields({ fields }: { fields: Record<string, unknown> }) {
const sections = recordList(fields.sections);
const harvest = recordList(fields.harvest);
if (sections.length === 0 && harvest.length === 0) return null;
return (
<>
{sections.length === 0 ? null : (
<Section title="Sections">
<ol className="min-w-0 space-y-3">
{sections.map((section, index) => {
const name = text(section.name);
if (!name) 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">{name}</p>
<Note label="What goes here" value={text(section.prompt)} />
{/* Evidence rules are the reason a case study can be shown to
the next customer at all see AGENTS.md §4. */}
<Note label="Evidence rule" value={text(section.evidenceRule)} />
</li>
);
})}
</ol>
</Section>
)}
{harvest.length === 0 ? null : (
<Section title="When to capture it">
<ScrollTable
head={['When', 'Capture', 'Why']}
rows={harvest.map((entry) => [text(entry.when), text(entry.capture), text(entry.why)])}
/>
</Section>
)}
</>
);
}
function NarrativeFields({ fields }: { fields: Record<string, unknown> }) {
const narratives = recordList(fields.narratives);
if (narratives.length === 0) return null;
return (
<>
{narratives.map((narrative, index) => {
const audience = text(narrative.audience);
if (!audience) return null;
const body = text(narrative.text);
return (
<Section key={index} title={audience}>
<Note label="What they already believe" value={text(narrative.belief)} />
{body ? (
<p className="min-w-0 whitespace-pre-wrap break-words rounded-lg border border-border bg-surface-2 p-3 text-sm leading-6">
{body}
</p>
) : null}
<Note label="Analogy that works" value={text(narrative.analogyThatWorks)} />
<Note label="Analogy that fails" value={text(narrative.analogyThatFails)} tone="warning" />
<Note label="What they ask next" value={text(narrative.nextQuestion)} />
<Note label="Answer" value={text(narrative.answer)} />
</Section>
);
})}
</>
);
}
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;
return (
<>
{stages.map((entry, index) => {
const stage = stageLabel(entry.stage);
if (!stage) return null;
const days = number(entry.typicalDays);
return (
<Section key={index} title={stage} aside={days === null ? undefined : `~${days} days`}>
<Note label="Entry" value={text(entry.entry)} />
<Note label="What it does" value={text(entry.does)} />
<Note label="Exit" value={text(entry.exit)} />
<Pills label="Artefacts" items={textList(entry.artifacts)} />
<Pills label="Who is involved" items={textList(entry.involves)} />
{/* Where the stage dies is the part of a playbook that gets read
twice the sequence is obvious, the failure is not. */}
<Note label="How it dies" value={text(entry.diesBy)} tone="warning" />
<Note label="Stall symptom" value={text(entry.stallSymptom)} tone="warning" />
<Note label="Unstick move" value={text(entry.unstickMove)} />
</Section>
);
})}
{research ? (
<Section title="Research interface">
<Pills label="Scope must contain" items={textList(research.scopeMustContain)} />
<Pills label="Owed back" items={textList(research.oweBack)} />
</Section>
) : null}
{promotion.length === 0 ? null : (
<Section title="What to promote, and when">
<ScrollTable
head={['Trigger', 'Promote', 'Into']}
rows={promotion.map((entry) => [text(entry.trigger), text(entry.promote), text(entry.into)])}
/>
</Section>
)}
</>
);
}
// ------------------------------------------------------------------- fragments
function Section({
title,
aside,
icon,
children,
}: {
title: string;
aside?: string;
icon?: ReactNode;
children: ReactNode;
}) {
return (
<section className="min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h3 className="flex min-w-0 items-center gap-2 break-words font-semibold leading-snug">
{icon ? <span className="shrink-0 text-muted">{icon}</span> : null}
{title}
</h3>
{aside ? <span className="nums whitespace-nowrap text-xs text-muted">{aside}</span> : null}
</div>
<div className="mt-3 min-w-0 space-y-3">{children}</div>
</section>
);
}
/** A labelled paragraph. Renders nothing at all when the value is absent. */
function Note({
label,
value,
tone,
}: {
label: string;
value: string | null;
tone?: 'warning';
}) {
if (!value) return null;
return (
<p className="mt-2 min-w-0 break-words text-sm leading-6">
<span
className={cn(
'mr-2 text-xs font-medium uppercase tracking-wide',
tone === 'warning' ? 'text-warning' : 'text-muted',
)}
>
{label}
</span>
{value}
</p>
);
}
function Pills({ label, items }: { label: string; items: string[] }) {
if (items.length === 0) return null;
return (
<div className="mt-2 min-w-0">
<span className="text-xs font-medium uppercase tracking-wide text-muted">{label}</span>
<div className="mt-1 flex min-w-0 flex-wrap gap-1.5">
{items.map((item, index) => (
<Badge key={index} tone="neutral" className="min-w-0">
<span className="truncate">{item}</span>
</Badge>
))}
</div>
</div>
);
}
/**
* A table whose overflow stays inside its own box. Rows are dropped when every
* cell in them is empty, so a partially-authored list does not render as a
* column of blank stripes.
*/
function ScrollTable({ head, rows }: { head: string[]; rows: (string | null)[][] }) {
const present = rows.filter((row) => row.some((cell) => cell !== null));
if (present.length === 0) return null;
return (
<div className="scroll-x min-w-0 rounded-lg border border-border">
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
<thead className="border-b border-border bg-surface-2">
<tr>
{head.map((heading) => (
<th key={heading} className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted">
{heading}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{present.map((row, index) => (
<tr key={index} className="transition-colors hover:bg-surface-2">
{row.map((cell, cellIndex) => (
<td key={cellIndex} className="max-w-[28rem] px-3 py-2 align-top">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
// -------------------------------------------------------------------- narrowing
/**
* 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
* written rather than dropped: content authored ahead of an ontology change is
* still worth reading.
*/
function stageLabel(value: unknown): string | null {
const raw = text(value);
if (!raw) return null;
return DEMAND_STAGE_LABELS[raw as DemandStage] ?? raw;
}
@@ -0,0 +1,263 @@
/**
* Half of the loop: library template engagement artefact.
*
* The picker is a list rather than a grid of `TemplateCard`s, because the
* question being answered here is not "what is in the library" but "which of
* these four is the one for this stage" so the rows are dense, the stage
* filter opens pre-set to the deal's own stage, and usage is on every row.
* Usage is the only quality signal the library has: a v3 used eleven times is
* tested and a v1 used never is somebody's draft, and that distinction matters
* more when copying into a live deal than when browsing.
*
* Instantiating copies the body and fields and increments the template's
* usage count, which is what closes the template to in-place edits (§7a). That
* is stated on the dialog rather than left to be discovered later by whoever
* tries to fix a typo in it.
*/
import { useDeferredValue, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Library, RefreshCw, Search } from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KINDS,
MOTION_KIND_LABELS,
type DemandStage,
type MotionKind,
} from '@pig/core';
import { get, post } from '@/lib/api';
import { Badge, Button, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MotionKindBadge } from './MotionKindBadge';
/** `GET /api/motion/templates` — summaries, so no body and no fields. */
interface TemplateRow {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: 'private' | 'shared';
isSystem: boolean;
usageCount: number;
updatedAt: string;
}
export function InstantiateDialog({
engagementId,
defaultStage = null,
defaultKind = null,
open,
onOpenChange,
}: {
engagementId: string;
/** The deal's stage, so the list opens on the templates that serve it. */
defaultStage?: DemandStage | null;
/** Set when the caller is after one kind — a framework to score against. */
defaultKind?: MotionKind | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const [kind, setKind] = useState<'all' | MotionKind>(defaultKind ?? 'all');
const [stage, setStage] = useState<'all' | DemandStage>(defaultStage ?? 'all');
const [query, setQuery] = useState('');
const search = useDeferredValue(query.trim());
const templates = useQuery({
queryKey: ['motion', 'library', { kind, stage, search }],
queryFn: () => {
const params = new URLSearchParams();
if (kind !== 'all') params.set('kind', kind);
if (stage !== 'all') params.set('stage', stage);
if (search) params.set('q', search);
const suffix = params.toString();
return get<{ templates: TemplateRow[]; truncated: boolean }>(
`/api/motion/templates${suffix ? `?${suffix}` : ''}`,
);
},
enabled: open,
});
const instantiate = useMutation({
mutationFn: (templateId: string) =>
post<{ artifact: { id: string } }>(`/api/motion/engagements/${engagementId}/artifacts`, {
templateId,
}),
onSuccess: async () => {
// The prefix invalidation is deliberate: instantiating moves the
// template's usage count too, so the library and the OS home are stale
// the moment this succeeds, not only the engagement.
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Artefact added to the engagement');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
const rows = templates.data?.templates ?? [];
const filtered = kind !== 'all' || stage !== 'all' || Boolean(search);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] w-[calc(100vw-1.5rem)] max-w-2xl grid-rows-[auto_auto_minmax(0,1fr)] overflow-hidden p-4 sm:p-6">
<DialogHeader className="pr-11">
<DialogTitle>Instantiate from the library</DialogTitle>
<DialogDescription>
The body and structured fields are copied into this engagement, and the template is
closed to in-place edits from here on later changes to it become a new version.
</DialogDescription>
</DialogHeader>
<div className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_9.5rem_9.5rem]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
<Input
aria-label="Search the library"
className="pl-9"
placeholder="Search title or summary"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<Select value={kind} onValueChange={(value) => setKind(value as typeof kind)}>
<SelectTrigger aria-label="Filter by kind" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((value) => (
<SelectItem key={value} value={value}>
{MOTION_KIND_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Select value={stage} onValueChange={(value) => setStage(value as typeof stage)}>
<SelectTrigger aria-label="Filter by stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">All stages</SelectItem>
{DEMAND_STAGES.map((value) => (
<SelectItem key={value} value={value}>
{DEMAND_STAGE_LABELS[value]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="min-w-0 overflow-y-auto">
{templates.isLoading ? (
<div className="flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-20" />
))}
</div>
) : null}
{templates.isError ? (
<EmptyState
icon={<AlertTriangle />}
title="Library unavailable"
description={
templates.error instanceof Error
? templates.error.message
: 'The library could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void templates.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<EmptyState
icon={<Library />}
title={filtered ? 'No template matches' : 'The library is empty'}
description={
filtered
? 'Clear a filter, or write the artefact from scratch and promote it once it has proved itself.'
: 'Nothing has been published yet. An artefact written here can be promoted into the library once it is final.'
}
/>
) : null}
{rows.length ? (
<ul className="flex min-w-0 flex-col gap-2">
{rows.map((template) => (
<li
key={template.id}
className="flex min-w-0 flex-col gap-2 rounded-xl border border-border p-3 sm:flex-row sm:items-center"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
<span className="nums whitespace-nowrap text-xs text-muted">
v{template.version} ·{' '}
{template.usageCount === 1
? 'used once'
: `used ${template.usageCount} times`}
</span>
</div>
<p className="mt-1.5 min-w-0 break-words font-medium leading-snug">
{template.title}
</p>
{template.summary ? (
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<Button
type="button"
variant="primary"
className="w-full shrink-0 sm:w-auto"
disabled={instantiate.isPending}
onClick={() => instantiate.mutate(template.id)}
>
Use this
</Button>
</li>
))}
</ul>
) : null}
{templates.data?.truncated ? (
<p className="mt-3 text-xs text-muted">
The library is wider than this answer. Narrow it with a kind, a stage or a search.
</p>
) : null}
</div>
</DialogContent>
</Dialog>
);
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Motion bodies, rendered as markdown.
*
* A template body is a document a discovery guide, a playbook, a narrative
* and the app had nowhere to render one: Piggy's transcript uses Streamdown,
* which is built for a half-finished token stream in a 22rem dock, not for a
* page of authored prose. This is the page-width counterpart, on plain
* `react-markdown` with GFM for the tables and task lists the content uses.
*
* Every element is styled from the map below. There is no
* `@tailwindcss/typography` in this repo and one is deliberately not being
* added for this, so there is no `prose` to fall back on and an element absent
* from the map renders with bare browser defaults.
*
* `rehypePlugins` is deliberately empty. Without `rehype-raw`, react-markdown
* does not render embedded HTML at all, and its default `urlTransform` already
* drops `javascript:` and other non-navigational protocols so the safe
* behaviour here is the behaviour of adding nothing. Template bodies are
* author-written but they are also promoted out of engagement artefacts that
* anyone with `motion:write` can edit, so they are treated as untrusted.
*/
import { isValidElement, type ComponentProps, type CSSProperties, type ReactNode } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ArrowUpRight } from 'lucide-react';
import { cn } from '@/components/ui';
import { CodeBlock } from './CodeBlock';
/** Fenced blocks carry their language as `language-<name>` on the `code` element. */
const LANGUAGE_CLASS = /language-([\w-]+)/;
export function Markdown({ content, className }: { content: string; className?: string }) {
return (
<div
className={cn(
// Block rhythm lives on the container rather than on each element, so
// the spacing between a heading and the paragraph under it does not
// depend on which of the two carries the margin.
'min-w-0 space-y-4 break-words text-sm leading-6 text-fg [&>*:first-child]:pt-0',
className,
)}
>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={MARKDOWN_COMPONENTS}>
{content}
</ReactMarkdown>
</div>
);
}
const MARKDOWN_COMPONENTS: Components = {
p: ({ children }) => <p className="leading-6">{children}</p>,
/*
* Headings buy their air with padding, not margin the container's
* `space-y-4` sets the gap below, and a margin above would be collapsed
* against it inconsistently. The scale stays close to body size: these are
* section headings inside a card, not the page's own title.
*/
h1: ({ children }) => <h1 className="pt-4 text-xl font-semibold tracking-tight">{children}</h1>,
h2: ({ children }) => <h2 className="pt-4 text-lg font-semibold tracking-tight">{children}</h2>,
h3: ({ children }) => <h3 className="pt-3 text-base font-semibold">{children}</h3>,
h4: ({ children }) => <h4 className="pt-2 text-sm font-semibold">{children}</h4>,
h5: ({ children }) => <h5 className="pt-2 text-sm font-medium text-muted">{children}</h5>,
h6: ({ children }) => (
<h6 className="pt-2 text-xs font-medium uppercase tracking-wide text-muted">{children}</h6>
),
ul: ({ children }) => <ul className="list-disc space-y-1.5 pl-5 marker:text-muted">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal space-y-1.5 pl-5 marker:text-muted">{children}</ol>,
// A nested list is the first *element* child of its item even when prose
// precedes it, so the parent's `space-y` never reaches it.
li: ({ children }) => <li className="leading-6 [&>ol]:mt-1.5 [&>ul]:mt-1.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold text-fg">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
del: ({ children }) => <del className="text-muted line-through">{children}</del>,
a: MarkdownLink,
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-border pl-4 text-muted [&>*+*]:mt-2">
{children}
</blockquote>
),
hr: () => <hr className="border-border" />,
img: ({ src, alt }) => (
// `referrerPolicy` so an image URL that arrived with a promoted artefact
// cannot use the referer to learn which template the reader had open.
<img
src={typeof src === 'string' ? src : undefined}
alt={alt ?? ''}
loading="lazy"
referrerPolicy="no-referrer"
className="max-w-full rounded-lg border border-border"
/>
),
/*
* The fence chrome is built entirely in `pre`, which never renders the
* `code` element react-markdown handed it it reads the language and the
* text off it instead. That is what makes the `code` entry below reachable
* only for inline code: react-markdown 10 stopped passing an `inline` flag,
* and the usual replacement guessing from the `language-` class gets a
* fenced block with no language wrong every time.
*/
pre: ({ children }) => <CodeFence>{children}</CodeFence>,
code: ({ children }) => (
<code className="rounded border border-border bg-surface-2 px-1 py-0.5 font-mono text-[0.85em]">
{children}
</code>
),
table: ({ children }) => (
// Without this the widest table on the page sets the width of the page,
// and every route scrolls sideways on a phone.
<div className="scroll-x rounded-lg border border-border">
{/* `w-max min-w-full`: fill the box when the table is narrow, spill into
the scroller rather than squash the columns when it is not. */}
<table className="w-max min-w-full border-collapse text-left text-[13px] leading-5">
{children}
</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border bg-surface-2">{children}</thead>,
tbody: ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
// A row highlight is what lets you keep your place across a table that is
// wider than the pane and has been scrolled sideways.
tr: ({ children }) => <tr className="transition-colors hover:bg-surface-2">{children}</tr>,
th: ({ children, style }) => (
<th
className="whitespace-nowrap px-3 py-2 align-bottom font-medium text-muted"
style={alignStyle(style)}
>
{children}
</th>
),
// No `nums` here, deliberately: tabular figures on every cell render an
// author's prose column in the digit-width of a ledger.
td: ({ children, style }) => (
<td className="px-3 py-2 align-top" style={alignStyle(style)}>
{children}
</td>
),
};
/**
* Links in a template body point out of PIG a vendor's docs, a paper, a
* customer's status page so they open in a new tab rather than navigating a
* workspace someone has unsaved edits in, carry no referer, and wear a marker
* glyph so a plausible phrase cannot pass itself off as internal navigation.
*/
function MarkdownLink({ href, children }: ComponentProps<'a'>) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
title={href}
className="font-medium text-info underline decoration-border underline-offset-2 hover:decoration-info"
>
{children}
<ArrowUpRight className="ml-0.5 inline size-3 align-[-0.1em]" aria-hidden />
</a>
);
}
/**
* A fenced code block, built from the `code` element rather than around it.
*
* The chrome the language caption and the copy button comes from
* `CodeBlock`, so a mermaid fence inside a promoted body offers the same copy
* action a mermaid field does in `FieldsView`. They are the same thing to the
* person reading them.
*/
function CodeFence({ children }: { children: ReactNode }) {
const element = isValidElement<{ className?: string; children?: ReactNode }>(children)
? children
: null;
const language = LANGUAGE_CLASS.exec(element?.props.className ?? '')?.[1];
const text = codeText(element ? element.props.children : children);
return <CodeBlock source={text} language={language} />;
}
/**
* GFM column alignment the `---:` in a delimiter row is the one piece of
* element styling the markdown itself owns, and a currency column that
* silently reverts to the left is the difference between a readable table and
* a wall. It reaches the cell as `style.textAlign`; only that property is
* taken, so nothing else an author writes can style the page.
*/
function alignStyle(style: CSSProperties | undefined): CSSProperties | undefined {
const value = style?.textAlign;
return value === 'right' || value === 'center' || value === 'left' ? { textAlign: value } : undefined;
}
/** The fence body reaches us as React children, normally one text node deep. */
function codeText(children: ReactNode): string {
if (typeof children === 'string') return children;
if (Array.isArray(children)) return (children as ReactNode[]).map(codeText).join('');
if (isValidElement<{ children?: ReactNode }>(children)) return codeText(children.props.children);
return '';
}
@@ -0,0 +1,62 @@
/**
* Which of the nine kinds a template or artefact is.
*
* The tone is neutral for every kind, deliberately. The badge palette here is
* semantic positive, warning, danger, info mean something about a number
* and the kinds are a taxonomy, not a severity scale; colouring them would
* teach people to read "case study" as good news. The differentiation comes
* from the glyph instead, which is what lets a mixed list of artefacts be
* scanned by shape rather than read word by word.
*/
import {
BookOpen,
FileText,
FlaskConical,
Gauge,
MessagesSquare,
Network,
Route,
Search,
Tags,
type LucideIcon,
} from 'lucide-react';
import { MOTION_KIND_DESCRIPTIONS, MOTION_KIND_LABELS, type MotionKind } from '@pig/core';
import { Badge, cn } from '@/components/ui';
const KIND_ICONS: Record<MotionKind, LucideIcon> = {
discovery: Search,
qualification: Gauge,
poc: FlaskConical,
proposal: FileText,
pricing: Tags,
architecture: Network,
case_study: BookOpen,
narrative: MessagesSquare,
playbook: Route,
};
export function MotionKindBadge({
kind,
/** Glyph only, for a dense row where the label is already in the title. */
iconOnly = false,
className,
}: {
kind: MotionKind;
iconOnly?: boolean;
className?: string;
}) {
const Icon = KIND_ICONS[kind];
const label = MOTION_KIND_LABELS[kind];
return (
<Badge
tone="neutral"
// The description is the only definition of a kind most people will ever
// read, and there is nowhere else on a card to put it.
title={`${label}${MOTION_KIND_DESCRIPTIONS[kind]}`}
className={cn('min-w-0 max-w-full', className)}
>
<Icon className="size-3 shrink-0" aria-hidden />
{iconOnly ? <span className="sr-only">{label}</span> : <span className="truncate">{label}</span>}
</Badge>
);
}
@@ -0,0 +1,287 @@
/**
* Scoring a deal against a qualification framework.
*
* The arithmetic is `motionScoreBasisPoints` from `@pig/core` the same pure
* function the API calls before it writes the row. That import is the whole
* design of this component: a client that computes the total itself would
* eventually disagree with the server about a number people act on, and a
* qualification score that reads 74% while you are filling the form in and
* 71% once it is saved is worse than one the form never showed at all.
* Nothing here posts a score; it posts the dimensions and lets the server
* recompute, so the two can never diverge even if this file is wrong.
*
* Every dimension must be answered before Save enables. A partial score is not
* a smaller score the maximum shrinks with it, so answering only the three
* dimensions that went well produces a band of "Strategic" on a deal nobody
* has qualified. The running total shown while the form is incomplete is
* labelled provisional for exactly that reason.
*
* `fields` is authored JSON and arrives unvalidated, the same way it does in
* `FieldsView`: a dimension without an id cannot be posted (the API keys on
* it) and a dimension without anchors cannot be scored honestly, so both are
* dropped rather than rendered as an empty control.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowDownRight, ArrowUpRight, Minus } from 'lucide-react';
import { toast } from 'sonner';
import {
MOTION_MAX_DIMENSION_SCORE,
MOTION_MIN_DIMENSION_SCORE,
motionBand,
motionScoreBasisPoints,
type MotionDimensionScore,
} from '@pig/core';
import { post } from '@/lib/api';
import { Badge, Button, cn } from '@/components/ui';
import { Textarea } from '@/components/ui/textarea';
import { anchorScale, asRecord, number, text, type AnchorRow } from './fields';
import { percent } from './format';
/** A dimension this component was able to make sense of. */
interface ScorableDimension {
id: string;
name: string;
weight: number;
group: string | null;
why: string | null;
anchors: AnchorRow[];
}
export function QualificationScorer({
engagementId,
frameworkTemplateId,
fields,
previousBasisPoints = null,
onScored,
onCancel,
}: {
engagementId: string;
/** Recorded on the score so the framework behind it stays identifiable. */
frameworkTemplateId: string | null;
/** The framework's `fields`. Untrusted author JSON — see the header. */
fields: unknown;
/** The last score on this engagement, if there is one, for the movement. */
previousBasisPoints?: number | null;
onScored: () => void;
onCancel: () => void;
}) {
const queryClient = useQueryClient();
const dimensions = useMemo(() => scorableDimensions(fields), [fields]);
const [answers, setAnswers] = useState<Record<string, number>>({});
const [note, setNote] = useState('');
const answered: MotionDimensionScore[] = dimensions
.filter((dimension) => answers[dimension.id] !== undefined)
.map((dimension) => ({
id: dimension.id,
weight: dimension.weight,
score: answers[dimension.id] as number,
}));
const complete = dimensions.length > 0 && answered.length === dimensions.length;
const basisPoints = motionScoreBasisPoints(answered);
const band = motionBand(basisPoints);
const movement =
complete && previousBasisPoints !== null ? basisPoints - previousBasisPoints : null;
const save = useMutation({
mutationFn: () =>
post<{ score: { id: string } }>(`/api/motion/engagements/${engagementId}/scores`, {
dimensions: answered,
frameworkTemplateId,
note: note.trim() ? note.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Qualification scored');
onScored();
},
onError: (error: Error) => toast.error(error.message),
});
if (dimensions.length === 0) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">This framework has no scorable dimensions</p>
<p className="mt-1 text-sm text-muted">
A dimension needs an id, a name and at least one anchor before it can be scored. Fix
the framework in the library, then score against a new version of it.
</p>
<Button className="mt-4" type="button" variant="outline" onClick={onCancel}>
Close
</Button>
</div>
);
}
return (
<form
className="mt-5 flex min-w-0 flex-col gap-5 pb-6"
onSubmit={(event) => {
event.preventDefault();
save.mutate();
}}
>
{/* Sticky, because the reason to show a running total at all is to let
somebody see a marginal answer move the band while they are still
looking at the anchors that produced it. */}
<div className="sticky top-0 z-10 -mx-1 min-w-0 rounded-xl border border-border bg-surface p-4">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="nums text-3xl font-semibold leading-none">{percent(basisPoints)}</span>
<Badge tone={band.tone}>{band.label}</Badge>
{movement === null ? null : <Movement delta={movement} />}
</div>
<p className="mt-2 text-xs text-muted">
{complete ? (
<>
Weighted across {dimensions.length} dimensions ·{' '}
<span className="nums">{basisPoints}</span> basis points
</>
) : (
<>
Provisional {answered.length} of {dimensions.length} scored. The maximum shrinks
with the dimensions you leave blank, so this band is not the deal's band yet.
</>
)}
</p>
</div>
<div className="min-w-0 space-y-4">
{dimensions.map((dimension) => (
<fieldset key={dimension.id} className="min-w-0 rounded-xl border border-border p-3">
<legend className="flex min-w-0 flex-wrap items-center gap-2 px-1">
<span className="min-w-0 break-words font-medium leading-6">{dimension.name}</span>
{dimension.group ? <Badge tone="neutral">{dimension.group}</Badge> : null}
<span className="nums whitespace-nowrap text-xs text-muted">
{dimension.weight === 0 ? 'no weight' : `weight ${dimension.weight}`}
</span>
</legend>
{dimension.why ? (
<p className="min-w-0 break-words text-sm leading-6 text-muted">{dimension.why}</p>
) : null}
<div className="mt-2 min-w-0 space-y-1.5">
{dimension.anchors.map((anchor) => {
const selected = answers[dimension.id] === anchor.score;
return (
<label
key={anchor.score}
className={cn(
'tap flex min-h-11 min-w-0 cursor-pointer gap-3 rounded-lg border p-2.5',
selected ? 'border-brand bg-surface-2' : 'border-border hover:bg-surface-2',
)}
>
<input
type="radio"
className="sr-only"
name={`dimension-${dimension.id}`}
value={anchor.score}
checked={selected}
onChange={() =>
setAnswers((current) => ({ ...current, [dimension.id]: anchor.score }))
}
/>
<span
className={cn(
'nums flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-semibold',
selected ? 'bg-primary text-primary-foreground' : 'bg-surface-2 text-muted',
)}
aria-hidden
>
{anchor.score}
</span>
<span className="min-w-0 break-words text-sm leading-6">{anchor.anchor}</span>
</label>
);
})}
</div>
</fieldset>
))}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<label className="text-sm font-medium" htmlFor="qualification-note">
What moved since the last score
</label>
<Textarea
id="qualification-note"
className="min-h-24"
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder="The evidence behind the answers that changed."
/>
</div>
{save.isError ? <p className="text-sm text-danger">{save.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
type="submit"
variant="primary"
disabled={!complete || save.isPending}
title={complete ? undefined : 'Score every dimension first.'}
>
Record score
</Button>
</div>
</form>
);
}
function Movement({ delta }: { delta: number }) {
if (delta === 0) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted">
<Minus className="size-3.5" aria-hidden />
Unchanged
</span>
);
}
const Icon = delta > 0 ? ArrowUpRight : ArrowDownRight;
return (
<span
className={cn(
'nums inline-flex items-center gap-1 text-xs font-medium',
delta > 0 ? 'text-positive' : 'text-danger',
)}
>
<Icon className="size-3.5" aria-hidden />
{delta > 0 ? '+' : ''}
{percent(Math.abs(delta))}
</span>
);
}
// ------------------------------------------------------------------ narrowing
function scorableDimensions(fields: unknown): ScorableDimension[] {
const record = asRecord(fields);
const raw = Array.isArray(record?.dimensions) ? record.dimensions : [];
const seen = new Set<string>();
const scorable: ScorableDimension[] = [];
for (const entry of raw) {
const dimension = asRecord(entry);
if (!dimension) continue;
const id = text(dimension.id);
const name = text(dimension.name);
if (!id || !name || seen.has(id)) continue;
const anchors = anchorScale(dimension.anchors);
if (anchors.length === 0) continue;
seen.add(id);
scorable.push({
id,
name,
// Clamped to the API's own bound rather than trusted: a weight the seed
// author typed as 1500 would otherwise fail zod after the whole form is
// filled in, which reads as the save being broken.
weight: Math.min(1_000, Math.max(0, Math.round(number(dimension.weight) ?? 0))),
group: text(dimension.group),
why: text(dimension.why),
anchors,
});
}
return scorable;
}
@@ -0,0 +1,98 @@
/**
* The demand motion, across the top.
*
* The eight open stages are the spine of the whole feature Motion binds
* artefacts to them rather than inventing a second pipeline so the rail is
* the one control that appears on the OS home, the library filter and the
* engagement workspace, and it has to mean the same thing in all three.
*
* The closed stages are absent because `DEMAND_OPEN_STAGES` is the default:
* a won or lost deal has left the motion, and a rail segment nobody can file
* work against is a segment people ask about once a quarter.
*
* It scrolls horizontally rather than wrapping. Wrapping puts `procurement`
* under `qualification` at 393px, which reads as a second row of the sequence
* starting over; a scroller keeps the order legible and keeps the overflow
* inside this box instead of dragging the page sideways.
*/
import { DEMAND_OPEN_STAGES, DEMAND_STAGE_LABELS, type DemandStage } from '@pig/core';
import { cn } from '@/components/ui';
export function StageRail({
counts,
coverage,
stages = DEMAND_OPEN_STAGES,
countLabel = 'engagements',
coverageLabel = 'templates',
activeStage,
onSelect,
className,
}: {
/** The headline figure per stage. A stage absent from the record reads as 0. */
counts: Partial<Record<DemandStage, number>>;
/** The second figure, if the caller has one — library cover, typically. */
coverage?: Partial<Record<DemandStage, number>>;
stages?: readonly DemandStage[];
countLabel?: string;
coverageLabel?: string;
activeStage?: DemandStage | null;
/** Omit to render a read-only rail: a non-interactive button is a trap. */
onSelect?: (stage: DemandStage) => void;
className?: string;
}) {
return (
<div className={cn('scroll-x min-w-0 pb-1', className)}>
<ol className="flex min-w-0 items-stretch gap-2">
{stages.map((stage) => {
const count = counts[stage] ?? 0;
const covered = coverage?.[stage];
const active = activeStage === stage;
const body = (
<>
<span className="truncate text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[stage]}
</span>
<span className={cn('nums text-2xl font-semibold leading-none', count === 0 && 'text-muted')}>
{count}
</span>
{covered === undefined ? null : (
// A stage with no template is the finding this rail exists to
// surface — it is where the motion stops repeating — so it is
// called out rather than shown as another grey zero.
<span className={cn('nums truncate text-xs', covered === 0 ? 'text-warning' : 'text-muted')}>
{covered === 0 ? `No ${coverageLabel}` : `${covered} ${coverageLabel}`}
</span>
)}
</>
);
const shape = cn(
'tap flex w-[8.5rem] shrink-0 flex-col justify-between gap-2 rounded-xl border p-3 text-left',
active ? 'border-brand bg-surface-2' : 'border-border bg-surface',
);
return (
<li key={stage} className="flex min-w-0">
{onSelect ? (
<button
type="button"
onClick={() => onSelect(stage)}
aria-pressed={active}
aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}
className={cn(shape, 'transition-colors hover:bg-surface-2')}
>
{body}
</button>
) : (
<div className={shape} aria-label={`${DEMAND_STAGE_LABELS[stage]}: ${count} ${countLabel}`}>
{body}
</div>
)}
</li>
);
})}
</ol>
</div>
);
}
@@ -0,0 +1,88 @@
/**
* One library template, as a browsable card.
*
* The three facts on it that are not the title are the ones that decide
* whether it is worth opening: who can see it, how many engagements have
* already used it, and whether it came back out of one. Usage is the closest
* thing the library has to a quality signal a v3 used eleven times is
* tested, a v1 used never is a draft somebody left and hiding it behind a
* click is what turns a library into a folder.
*/
import { Link } from 'react-router-dom';
import { Lock, Sparkles, Users } from 'lucide-react';
import { DEMAND_STAGE_LABELS } from '@pig/core';
import { Badge, Card, cn } from '@/components/ui';
import { MotionKindBadge } from './MotionKindBadge';
import type { MotionTemplateView } from './model';
import type { ReactNode } from 'react';
export function TemplateCard({
template,
to,
footer,
className,
}: {
template: MotionTemplateView;
/** Defaults to the template's own page; pass a filtered return path instead. */
to?: string;
/** Actions belonging to the calling page — instantiate, publish, fork. */
footer?: ReactNode;
className?: string;
}) {
return (
<Card className={cn('flex min-w-0 flex-col gap-3 p-4', className)}>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[template.stage]}</span>
</Badge>
{template.visibility === 'private' ? (
<Badge tone="warning" title="Only you and a platform admin can see this">
<Lock className="size-3 shrink-0" aria-hidden />
Private
</Badge>
) : null}
{template.isSystem ? (
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
<Sparkles className="size-3 shrink-0" aria-hidden />
Starter
</Badge>
) : null}
</div>
<div className="min-w-0">
{/* break-words, not truncate: the title is the only way to tell two
versions of the same lineage apart, and an unbroken word at 393px
is what drags the whole page sideways. */}
<h3 className="min-w-0 break-words font-semibold leading-snug">
<Link
to={to ?? `/motion/library/${template.id}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{template.title}
</Link>
</h3>
{template.summary ? (
<p className="mt-1 line-clamp-2 min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
) : null}
</div>
<div className="mt-auto flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">v{template.version}</span>
<span className="nums inline-flex min-w-0 items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
{template.originArtifactId ? (
// The loop, made visible. This row exists because an engagement
// proved it, which is the whole argument for the feature.
<span className="min-w-0 truncate">Promoted from an engagement</span>
) : null}
</div>
{footer ? <div className="flex min-w-0 flex-wrap items-center gap-2">{footer}</div> : null}
</Card>
);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Narrowing the authored JSON a template carries in `fields`.
*
* One rule per shape, in one place, because there are two readers of the same
* bytes and they must not disagree. `FieldsView` renders a qualification
* framework's dimensions and `QualificationScorer` turns the same dimensions
* into a form so the day whitespace-only anchors are made to render as an
* em-dash rather than vanish, a second copy of `text()` would leave the scorer
* still dropping them, and the reader would be shown five dimensions while the
* score was computed against four.
*
* Everything here is total: an unrecognised shape yields null or an empty
* list, never a throw. An omitted section is a bug someone reports; a white
* screen on one badly-typed row is an outage.
*/
import { MOTION_MAX_DIMENSION_SCORE, MOTION_MIN_DIMENSION_SCORE } from '@pig/core';
export function asRecord(value: unknown): Record<string, unknown> | null {
// `typeof null` is 'object' and an array is one too; both would satisfy a
// naive check and then read `undefined` off every property.
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/** A non-empty string, or null. Whitespace-only is treated as absent. */
export function text(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length === 0 ? null : trimmed;
}
export function number(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
/** The entries of a list that are objects. Anything else is dropped silently. */
export function recordList(value: unknown): Record<string, unknown>[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => asRecord(entry))
.filter((entry): entry is Record<string, unknown> => entry !== null);
}
export function textList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((entry) => text(entry)).filter((entry): entry is string => entry !== null);
}
export interface AnchorRow {
score: number;
anchor: string;
}
/**
* The 04 scale, from `@pig/core` rather than from the keys present in the
* JSON, so a framework that forgot to write an anchor for 2 still offers the
* same scale everywhere it is read.
*/
export function anchorScale(anchors: unknown): AnchorRow[] {
const record = asRecord(anchors);
if (!record) return [];
const rows: AnchorRow[] = [];
for (let score = MOTION_MIN_DIMENSION_SCORE; score <= MOTION_MAX_DIMENSION_SCORE; score += 1) {
const anchor = text(record[String(score)]);
if (anchor) rows.push({ score, anchor });
}
return rows;
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Display formatting shared by every page that renders a qualification score.
*
* One formatter, because the same 6250 was reading as `63%` on the Motion home
* and `62.5%` on the engagement that page links to and the difference lands
* exactly where a band boundary does. It lived in `QualificationScorer` until
* three pages that do not otherwise touch the scorer were importing from it.
*/
/** Basis points as a percentage. Rounded, never truncated — AGENTS.md §4. */
export function percent(basisPoints: number): string {
return `${(Math.round(basisPoints / 10) / 10).toFixed(1)}%`;
}
+47
View File
@@ -0,0 +1,47 @@
/**
* The shape a template arrives in on the wire.
*
* It describes a *response*, not the server's own row, so it is declared here
* rather than imported from `@pig/db`: a column the API does not serialise must
* be absent, and one it may not have joined yet must be optional, or the
* compiler asserts a guarantee the JSON does not carry. The enums are the
* exception those come from `@pig/core`, because a kind the ontology has
* dropped should stop compiling rather than keep rendering.
*
* `fields` is the deliberate hole in the type. It is author-written JSON whose
* shape varies by kind and is not validated on the way out of the database, so
* it arrives as `unknown` and is narrowed at the point of use in `FieldsView`.
* Typing it as the shape we hope for would move a runtime crash into a place
* where nobody is looking for it.
*
* There is only one type here on purpose. Eight more were written alongside it
* and never imported, and by the time anyone read them they no longer matched
* `services/motion.ts` `EngagementDetail` promised `.artifacts` where the
* endpoint returns `.stages`. Each page declares the subset of the response it
* actually reads, which is checked against the `get<T>()` call that fetches it;
* a shared file that nothing imports is checked against nothing at all.
*/
import type { DemandStage, MotionKind, MotionVisibility } from '@pig/core';
export interface MotionTemplateView {
id: string;
kind: MotionKind;
/** Stable across versions — the identity of the lineage, not of the row. */
slug: string;
version: number;
title: string;
summary: string;
body: string;
fields: unknown;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
ownerName?: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
+5 -1
View File
@@ -209,7 +209,11 @@ export function Stat({
: 'text-fg';
return (
<div className="card p-4">
// `min-w-0` for the reason `Card` carries it: `.card` does not, and a stat
// is always a grid child whose figure is `tabular-nums` and whose hint does
// not wrap mid-word. Without it a three-up row on a 393px phone refuses to
// shrink and the page scrolls sideways (AGENTS.md §5).
<div className="card min-w-0 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
<div className={cn('nums mt-1 text-2xl font-semibold leading-tight sm:text-3xl', toneClass)}>
{value}
+27 -5
View File
@@ -20,8 +20,11 @@ import {
FileSpreadsheet,
FileText,
GraduationCap,
Handshake,
LayoutDashboard,
Library,
MessageCircleMore,
Route,
Server,
Settings,
ShieldCheck,
@@ -33,7 +36,7 @@ import {
import type { Capability, Team } from '@pig/core';
import { canAny, type PermissionIdentity } from './permissions';
export const NAV_GROUPS = ['Intelligence', 'Marketplace', 'Records', 'Control'] as const;
export const NAV_GROUPS = ['Intelligence', 'Motion', 'Marketplace', 'Records', 'Control'] as const;
export type NavGroup = (typeof NAV_GROUPS)[number];
export interface NavItem {
@@ -61,6 +64,15 @@ export const NAV: NavItem[] = [
{ to: '/piggy', label: 'Piggy', icon: MessageCircleMore, group: 'Intelligence' },
{ to: '/learn', label: 'Learn', icon: GraduationCap, group: 'Intelligence' },
{ to: '/margin', label: 'Margin', icon: TrendingUp, group: 'Intelligence', primary: true },
// None of these is `primary`: the phone tab bar holds five and the ledger
// pages already hold all five. The three routes are reachable from the
// sidebar and from ⌘K, which a NavItem earns by structurally satisfying
// CommandDestination. Reads are gated on `book:read` like Accounts and
// Contracts, so no `requires` — the write capabilities are enforced per
// control on the page, not by hiding the destination.
{ to: '/motion', label: 'Motion', icon: Route, group: 'Motion' },
{ to: '/motion/library', label: 'Library', icon: Library, group: 'Motion' },
{ to: '/motion/engagements', label: 'Engagements', icon: Handshake, group: 'Motion' },
{ to: '/capacity', label: 'Capacity', icon: Server, group: 'Marketplace', primary: true },
{ to: '/demand', label: 'Demand', icon: Building2, group: 'Marketplace', primary: true },
{ to: '/supply', label: 'Supply', icon: Boxes, group: 'Marketplace', primary: true },
@@ -106,9 +118,19 @@ export function visibleNav(identity: PermissionIdentity | undefined): NavItem[]
});
}
/** The nav entry a pathname belongs to, for the header title and active state. */
/**
* The nav entry a pathname belongs to, for the header title and active state.
*
* The longest match wins, not the first. Motion is the first group whose own
* destinations nest `/motion` is a prefix of `/motion/library` and a
* first-match scan titles the library page "Motion". Longest-match is a no-op
* for every other route in the table.
*/
export function activeNavItem(items: readonly NavItem[], pathname: string): NavItem | undefined {
return items.find((item) =>
item.to === '/' ? pathname === '/' : pathname === item.to || pathname.startsWith(`${item.to}/`),
);
let match: NavItem | undefined;
for (const item of items) {
if (item.to === '/' ? pathname !== '/' : pathname !== item.to && !pathname.startsWith(`${item.to}/`)) continue;
if (!match || item.to.length > match.to.length) match = item;
}
return match;
}
+993
View File
@@ -0,0 +1,993 @@
/**
* The engagement workspace: one demand deal, worked with the motion.
*
* The stage rail is the spine, and it is the *deal's* stages rather than any
* state of the engagement's own an engagement that could disagree with its
* deal about what stage the work is at would be a second answer to a question
* that already has one.
*
* Two actions here are first-class because they are the loop: instantiate a
* template into a stage, and promote a finished artefact back into the library.
* Everything else on the page exists to make those two legible the score
* history because qualification is a movement rather than a number, and the
* per-stage grouping because "what did we write at proposal" is the question
* somebody opening this page a quarter later is actually asking.
*
* The loading, missing and failed branches are early returns rather than
* sibling conditionals. That is the detail-page pattern in this repo
* (`Account.tsx` is the same shape, 404 branch included), and it differs from
* the list pages deliberately: a detail page has nothing to render around the
* hole.
*/
import { useMemo, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
ArrowUpRight,
ChevronRight,
FileText,
Gauge,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import { toast } from 'sonner';
import {
ARTIFACT_STATUSES,
ARTIFACT_STATUS_LABELS,
DEMAND_OPEN_STAGES,
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
toPiggyPageRoute,
type ArtifactStatus,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { ApiError, get, patch, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Input,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { FieldsView } from '@/components/motion/FieldsView';
import { InstantiateDialog } from '@/components/motion/InstantiateDialog';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { QualificationScorer } from '@/components/motion/QualificationScorer';
import { percent } from '@/components/motion/format';
import { StageRail } from '@/components/motion/StageRail';
// --------------------------------------------------------------------- wire
interface ScoreView {
id: string;
frameworkTemplateId: string | null;
dimensions: unknown;
basisPoints: number;
band: string;
tone: MotionBandTone;
note: string | null;
scoredByUserId: string | null;
scoredAt: string;
}
interface ArtifactView {
id: string;
engagementId: string;
templateId: string | null;
kind: MotionKind;
stage: DemandStage;
title: string;
body: string;
fields: unknown;
status: ArtifactStatus;
authoredByUserId: string | null;
promotedTemplateId: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface EngagementView {
id: string;
demandDealId: string;
dealName: string | null;
/** The deal's stage. An engagement has none of its own — that is the point. */
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
ownerUserId: string | null;
playbookTemplateId: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: ScoreView | null;
}
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
usageCount: number;
}
/** `GET /api/motion/engagements/:id` — `stages` is all ten, always, in order. */
interface EngagementDetail {
engagement: EngagementView;
stages: { stage: DemandStage; artifacts: ArtifactView[] }[];
scores: ScoreView[];
playbook: TemplateSummary | null;
}
const WRITE_DENIED = 'Editing this engagement needs the motion:write permission.';
const PUBLISH_DENIED = 'Promoting into the library needs the motion:publish permission.';
// --------------------------------------------------------------------- page
export function Engagement() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const mayPublish = canAny(me, 'motion:publish');
const [stageFilter, setStageFilter] = useState<DemandStage | null>(null);
const [instantiating, setInstantiating] = useState<{ kind: MotionKind | null } | null>(null);
const [scoring, setScoring] = useState(false);
const [promoting, setPromoting] = useState<ArtifactView | null>(null);
const detail = useQuery({
queryKey: ['motion', 'engagements', id, 'detail'],
queryFn: () => get<EngagementDetail>(`/api/motion/engagements/${id}`),
enabled: Boolean(id),
retry: false,
});
const engagement = detail.data?.engagement;
usePageTitle(engagement?.dealName ?? 'Engagement');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: engagement?.dealName ? `Engagement — ${engagement.dealName}` : 'Engagement',
});
const groups = detail.data?.stages ?? [];
const counts = useMemo(() => {
const record: Partial<Record<DemandStage, number>> = {};
for (const group of groups) record[group.stage] = group.artifacts.length;
return record;
}, [groups]);
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This engagement is restricted"
description="An engagement sits on a demand deal, and reading the book needs the book permission. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Skeleton className="h-32" />
<Skeleton className="h-28" />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Handshake />}
title="No such engagement"
description="It has been removed, or the link was to an id that never existed."
/>
);
}
if (detail.error || !detail.data || !engagement) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagement unavailable"
description={
detail.error instanceof Error
? detail.error.message
: 'The workspace could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const artifacts = groups.flatMap((group) => group.artifacts);
const promoted = artifacts.filter((artifact) => artifact.promotedTemplateId).length;
const finalised = artifacts.filter((artifact) => artifact.status === 'final').length;
const frameworks = artifacts.filter((artifact) => artifact.kind === 'qualification');
const scores = detail.data.scores;
/*
* The eight open stages always, plus any closed stage that actually holds
* work or is where the deal now sits. A rail that hides `closed_won` would
* lose the case study written after the deal landed which is precisely the
* artefact most worth promoting.
*/
const railStages = DEMAND_STAGES.filter(
(stage) =>
DEMAND_OPEN_STAGES.includes(stage) ||
(counts[stage] ?? 0) > 0 ||
stage === engagement.stage,
);
const visibleGroups = stageFilter
? groups.filter((group) => group.stage === stageFilter)
: groups.filter((group) => group.artifacts.length > 0);
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<Header
engagement={engagement}
mayWrite={mayWrite}
onInstantiate={() => setInstantiating({ kind: null })}
/>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4" aria-label="Engagement totals">
<Stat label="Artefacts" value={artifacts.length} hint="Not archived" />
<Stat label="Final" value={finalised} hint="Promotable" />
<Stat
label="Promoted"
value={promoted}
hint="Back into the library"
tone={promoted ? 'positive' : 'default'}
/>
<Stat
label="Qualification"
value={engagement.latestScore ? percent(engagement.latestScore.basisPoints) : '—'}
hint={engagement.latestScore ? engagement.latestScore.band : 'Never scored'}
tone={engagement.latestScore?.tone === 'danger' ? 'danger' : engagement.latestScore?.tone === 'warning' ? 'warning' : 'default'}
/>
</section>
<section aria-label="Artefacts by stage" className="flex min-w-0 flex-col gap-2">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Where the work sits</h2>
{stageFilter ? (
<Button variant="ghost" onClick={() => setStageFilter(null)}>
Show every stage
</Button>
) : null}
</div>
<StageRail
counts={counts}
stages={railStages}
countLabel="artefacts"
activeStage={stageFilter ?? engagement.stage}
onSelect={(stage) => setStageFilter((current) => (current === stage ? null : stage))}
/>
<p className="text-xs text-muted">
{stageFilter
? `Filtered to ${DEMAND_STAGE_LABELS[stageFilter]}.`
: engagement.stage
? `The deal is at ${DEMAND_STAGE_LABELS[engagement.stage]}. Select a stage to filter the artefacts below.`
: 'Select a stage to filter the artefacts below.'}
</p>
</section>
<Qualification
scores={scores}
frameworks={frameworks}
mayWrite={mayWrite}
onScore={() => setScoring(true)}
onInstantiateFramework={() => setInstantiating({ kind: 'qualification' })}
/>
{detail.data.playbook ? <Playbook playbook={detail.data.playbook} /> : null}
<section className="flex min-w-0 flex-col gap-3" aria-label="Artefacts">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<h2 className="font-semibold">Artefacts</h2>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
<Library aria-hidden />
Instantiate from library
</Button>
</div>
{visibleGroups.length === 0 || visibleGroups.every((group) => group.artifacts.length === 0) ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<FileText />}
title={stageFilter ? 'Nothing filed at this stage' : 'No artefacts yet'}
description={
stageFilter
? 'Instantiate a template that serves this stage, or clear the filter to see the rest.'
: 'Instantiate a discovery guide or a qualification framework from the library. What comes out of this engagement can be promoted back into it.'
}
action={
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setInstantiating({ kind: null })}
>
Instantiate a template
</Button>
}
/>
</CardContent>
</Card>
) : (
visibleGroups.map((group) => (
<div key={group.stage} className="flex min-w-0 flex-col gap-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-muted">
{DEMAND_STAGE_LABELS[group.stage]}
</h3>
{group.artifacts.map((artifact) => (
<ArtifactPanel
key={artifact.id}
artifact={artifact}
mayWrite={mayWrite}
mayPublish={mayPublish}
onPromote={() => setPromoting(artifact)}
/>
))}
</div>
))
)}
</section>
{instantiating ? (
<InstantiateDialog
// Remounted per intent, so opening it to find a framework really does
// open on the frameworks rather than on whatever was filtered last.
key={instantiating.kind ?? 'any'}
engagementId={engagement.id}
defaultStage={engagement.stage}
defaultKind={instantiating.kind}
open
onOpenChange={(open) => {
if (!open) setInstantiating(null);
}}
/>
) : null}
<Sheet open={scoring} onOpenChange={setScoring}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>Score qualification</SheetTitle>
<SheetDescription>
Answered against the anchors, so two people reading the same evidence reach the same
number. Scores are appended, never edited the movement is the evidence that
qualification happened.
</SheetDescription>
</SheetHeader>
<ScoringPanel
engagementId={engagement.id}
frameworks={frameworks}
previousBasisPoints={scores[0]?.basisPoints ?? null}
onDone={() => setScoring(false)}
/>
</SheetContent>
</Sheet>
<PromoteSheet artifact={promoting} onClose={() => setPromoting(null)} />
</div>
);
}
// ------------------------------------------------------------------ sections
function Header({
engagement,
mayWrite,
onInstantiate,
}: {
engagement: EngagementView;
mayWrite: boolean;
onInstantiate: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: EngagementStatus) =>
patch<{ engagement: { id: string } }>(`/api/motion/engagements/${engagement.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Engagement marked ${ENGAGEMENT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<header className="flex min-w-0 flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{engagement.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[engagement.stage]}</span>
</Badge>
) : null}
<Badge tone={statusTone(engagement.status)}>
{ENGAGEMENT_STATUS_LABELS[engagement.status]}
</Badge>
<span className="nums text-xs text-muted">
Opened {shortDate(engagement.openedAt)}
{engagement.closedAt ? ` · closed ${shortDate(engagement.closedAt)}` : ''}
</span>
</div>
<h1 className="mt-1 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{engagement.dealName ?? 'Untitled deal'}
</h1>
<p className="mt-1 min-w-0 truncate text-sm text-muted">
{engagement.accountId ? (
<Link
to={`/accounts/${engagement.accountId}`}
className="underline-offset-4 hover:text-accent-fg hover:underline"
>
{engagement.accountName ?? 'Unknown account'}
</Link>
) : (
(engagement.accountName ?? 'Unknown account')
)}
</p>
{engagement.summary ? (
<p className="mt-2 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{engagement.summary}
</p>
) : null}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={engagement.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as EngagementStatus)}
>
<SelectTrigger
aria-label="Engagement status"
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-40 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ENGAGEMENT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ENGAGEMENT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onInstantiate}
>
<Library aria-hidden />
Instantiate
</Button>
</div>
</header>
);
}
/**
* The score, and what it did.
*
* The number on its own answers a question nobody asks twice. What a review
* actually needs is the direction: a deal that has moved 5,800 7,100 across
* three weeks is a different deal from one that has sat at 7,100 since it was
* opened, and the second one is the one where nothing has been learned.
*/
function Qualification({
scores,
frameworks,
mayWrite,
onScore,
onInstantiateFramework,
}: {
scores: ScoreView[];
frameworks: ArtifactView[];
mayWrite: boolean;
onScore: () => void;
onInstantiateFramework: () => void;
}) {
const latest = scores[0] ?? null;
const scorable = frameworks.length > 0;
return (
<Card className="flex min-w-0 flex-col gap-4 p-4 sm:p-5">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold">Qualification</h2>
<p className="mt-0.5 text-sm text-muted">
Weighted dimensions, scored against anchors. The band is what gets acted on.
</p>
</div>
{scorable ? (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={onScore}
>
<Gauge aria-hidden />
{latest ? 'Score again' : 'Score this deal'}
</Button>
) : (
<Button
variant="outline"
disabled={!mayWrite}
title={
mayWrite
? 'Scoring needs a qualification framework in this engagement.'
: WRITE_DENIED
}
onClick={onInstantiateFramework}
>
Add a framework
</Button>
)}
</div>
{latest ? (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="nums text-3xl font-semibold leading-none">
{percent(latest.basisPoints)}
</span>
<Badge tone={latest.tone}>{latest.band}</Badge>
<span className="nums text-xs text-muted">scored {shortDate(latest.scoredAt)}</span>
</div>
) : (
<p className="text-sm text-muted">
{scorable
? 'Not scored yet. The framework is here; the first score is the baseline everything after is read against.'
: 'Not scored yet, and there is no framework in this engagement to score against. Instantiate one from the library.'}
</p>
)}
{scores.length > 1 ? (
<div className="min-w-0 border-t border-border pt-3">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted">Movement</h3>
<ol className="min-w-0 space-y-2">
{scores.map((score, index) => {
const previous = scores[index + 1];
const delta = previous ? score.basisPoints - previous.basisPoints : null;
return (
<li key={score.id} className="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-3">
<span className="nums w-16 shrink-0 text-sm font-medium">
{percent(score.basisPoints)}
</span>
<span className="w-28 shrink-0">
<Badge tone={score.tone}>{score.band}</Badge>
</span>
<span
className={cn(
'nums w-20 shrink-0 text-xs font-medium',
delta === null ? 'text-muted' : delta > 0 ? 'text-positive' : delta < 0 ? 'text-danger' : 'text-muted',
)}
>
{delta === null
? 'baseline'
: delta === 0
? 'no change'
: `${delta > 0 ? '+' : ''}${percent(Math.abs(delta))}`}
</span>
<span className="min-w-0 flex-1 break-words text-xs text-muted">
{shortDate(score.scoredAt)}
{score.note ? ` · ${score.note}` : ''}
</span>
</li>
);
})}
</ol>
</div>
) : null}
</Card>
);
}
/** Which framework the scorer is filling in, when the engagement holds several. */
function ScoringPanel({
engagementId,
frameworks,
previousBasisPoints,
onDone,
}: {
engagementId: string;
frameworks: ArtifactView[];
previousBasisPoints: number | null;
onDone: () => void;
}) {
const [selected, setSelected] = useState(frameworks[0]?.id ?? '');
const framework = frameworks.find((artifact) => artifact.id === selected) ?? frameworks[0];
if (!framework) {
return (
<div className="mt-6 rounded-lg border border-border p-4">
<p className="font-medium">No framework in this engagement</p>
<p className="mt-1 text-sm text-muted">
Instantiate a qualification template first the dimensions and their anchors come from
it, and a score without them is a number two people would not agree on.
</p>
</div>
);
}
return (
<>
{frameworks.length > 1 ? (
<div className="mt-5 flex min-w-0 flex-col gap-1.5">
<Label htmlFor="scoring-framework">Framework</Label>
<Select value={framework.id} onValueChange={setSelected}>
<SelectTrigger id="scoring-framework" aria-label="Framework" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{frameworks.map((artifact) => (
<SelectItem key={artifact.id} value={artifact.id}>
{artifact.title}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
) : null}
<QualificationScorer
key={framework.id}
engagementId={engagementId}
// The template behind the artefact, not the artefact: the score records
// which framework produced it, and a from-scratch framework has none.
frameworkTemplateId={framework.templateId}
fields={framework.fields}
previousBasisPoints={previousBasisPoints}
onScored={onDone}
onCancel={onDone}
/>
</>
);
}
function Playbook({ playbook }: { playbook: TemplateSummary }) {
return (
<Card className="flex min-w-0 flex-col gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-medium uppercase tracking-wide text-muted">Playbook</p>
<p className="mt-1 min-w-0 break-words font-medium">{playbook.title}</p>
<p className="mt-0.5 line-clamp-2 min-w-0 break-words text-sm text-muted">
{playbook.summary}
</p>
</div>
<Button asChild variant="outline" className="shrink-0">
<Link to={`/motion/library/${playbook.id}`}>
Open v{playbook.version}
<ArrowUpRight aria-hidden />
</Link>
</Button>
</Card>
);
}
/**
* One artefact, with the two controls that move it: its status, and promotion.
*
* Promotion is refused by the API for an artefact that is not final and for one
* already promoted, so the button is disabled with the reason on it rather than
* hidden a control that vanishes teaches nothing, and "why can I not promote
* this" is a question the status select right beside it answers.
*/
function ArtifactPanel({
artifact,
mayWrite,
mayPublish,
onPromote,
}: {
artifact: ArtifactView;
mayWrite: boolean;
mayPublish: boolean;
onPromote: () => void;
}) {
const queryClient = useQueryClient();
const update = useMutation({
mutationFn: (status: ArtifactStatus) =>
patch<{ artifact: { id: string } }>(`/api/motion/artifacts/${artifact.id}`, { status }),
onSuccess: async (_data, status) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Marked ${ARTIFACT_STATUS_LABELS[status].toLocaleLowerCase()}`);
},
onError: (error: Error) => toast.error(error.message),
});
const promotable = artifact.status === 'final' && !artifact.promotedTemplateId && mayPublish;
const promoteReason = artifact.promotedTemplateId
? 'Already in the library. Promote a later revision instead.'
: artifact.status !== 'final'
? 'Only a final artefact may be promoted — the library is what the next deployment copies.'
: PUBLISH_DENIED;
return (
<Card className="flex min-w-0 flex-col gap-3 p-4">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={artifact.kind} />
<Badge tone={artifactTone(artifact.status)}>{ARTIFACT_STATUS_LABELS[artifact.status]}</Badge>
{artifact.promotedTemplateId ? (
<Badge tone="positive" title="This artefact became a library version">
<Sparkles className="size-3 shrink-0" aria-hidden />
In the library
</Badge>
) : null}
{artifact.templateId ? null : (
<Badge tone="neutral" title="Written here rather than instantiated">
From scratch
</Badge>
)}
</div>
<div className="min-w-0">
<h4 className="min-w-0 break-words font-semibold leading-snug">{artifact.title}</h4>
<p className="nums mt-0.5 text-xs text-muted">Updated {shortDate(artifact.updatedAt)}</p>
</div>
<details className="group min-w-0">
<summary className="tap flex cursor-pointer items-center gap-1.5 text-sm font-medium text-muted hover:text-fg">
<ChevronRight className="size-4 transition-transform group-open:rotate-90" aria-hidden />
Read it
</summary>
<div className="mt-3 min-w-0 border-t border-border pt-3">
{artifact.body ? <Markdown content={artifact.body} /> : null}
<FieldsView kind={artifact.kind} fields={artifact.fields} className="mt-4" />
</div>
</details>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Select
value={artifact.status}
disabled={!mayWrite || update.isPending}
onValueChange={(value) => update.mutate(value as ArtifactStatus)}
>
<SelectTrigger
aria-label={`Status of ${artifact.title}`}
title={mayWrite ? undefined : WRITE_DENIED}
className="h-11 w-36 min-w-0"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{ARTIFACT_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{ARTIFACT_STATUS_LABELS[status]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{artifact.promotedTemplateId ? (
<Button asChild variant="outline">
<Link to={`/motion/library/${artifact.promotedTemplateId}`}>
Open the library version
<ArrowUpRight aria-hidden />
</Link>
</Button>
) : (
<Button
variant="primary"
disabled={!promotable}
title={promotable ? undefined : promoteReason}
onClick={onPromote}
>
<Sparkles aria-hidden />
Promote to library
</Button>
)}
</div>
{promotable ? null : (
<p className="min-w-0 break-words text-xs text-muted">{promoteReason}</p>
)}
</Card>
);
}
/**
* Promotion, with the two fields the library reads and nothing else.
*
* The lineage is not offered as a control. An artefact instantiated from a
* template lands as the next version of that template's lineage, which is what
* makes "this came from playbook v3, and v4 is what we learned" true; letting
* somebody retarget it here would break the only chain that carries the claim.
*/
function PromoteSheet({ artifact, onClose }: { artifact: ArtifactView | null; onClose: () => void }) {
return (
<Sheet open={Boolean(artifact)} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
{artifact ? <PromoteForm key={artifact.id} artifact={artifact} onDone={onClose} /> : null}
</SheetContent>
</Sheet>
);
}
function PromoteForm({ artifact, onDone }: { artifact: ArtifactView; onDone: () => void }) {
const queryClient = useQueryClient();
const [title, setTitle] = useState(artifact.title);
const [summary, setSummary] = useState('');
const promote = useMutation({
mutationFn: () =>
post<{ template: { id: string; version: number } }>(
`/api/motion/artifacts/${artifact.id}/promote`,
{
title: title.trim() || artifact.title,
...(summary.trim() ? { summary: summary.trim() } : {}),
},
),
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success(`Promoted as v${result.template.version}`);
onDone();
},
onError: (error: Error) => toast.error(error.message),
});
return (
<>
<SheetHeader>
<SheetTitle>Promote to the library</SheetTitle>
<SheetDescription>
{artifact.templateId
? 'This lands as the next version of the lineage it came from, pointing back at both its predecessor and this artifact.'
: 'This starts a new lineage at version 1, pointing back at this artefact as the engagement that proved it.'}{' '}
Promoted templates are shared with everyone.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
promote.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-title">Title</Label>
<Input
id="promote-title"
required
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="promote-summary">Summary</Label>
<Textarea
id="promote-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="What the next person needs to know before copying this."
/>
<p className="text-xs text-muted">
Left blank, the previous version&apos;s summary carries over.
</p>
</div>
{promote.isError ? <p className="text-sm text-danger">{promote.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onDone}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={promote.isPending}>
Promote
</Button>
</div>
</form>
</>
);
}
// ------------------------------------------------------------------- helpers
function BackLink() {
return (
<Link
to="/motion/engagements"
className="tap -ml-1 inline-flex w-fit items-center gap-2 rounded-lg px-1 text-sm font-medium text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
All engagements
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
function statusTone(status: EngagementStatus): 'positive' | 'danger' | 'warning' | 'info' {
return status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
}
function artifactTone(status: ArtifactStatus): 'neutral' | 'info' | 'positive' {
return status === 'final' ? 'positive' : status === 'review' ? 'info' : 'neutral';
}
+452
View File
@@ -0,0 +1,452 @@
/**
* The Motion home, which exists to answer one question: is the motion actually
* repeating?
*
* Every panel here is a different way of asking it. The rail asks whether the
* library reaches the stages where work is actually sitting; the kind grid asks
* whether the nine kinds are covered or whether three of them are aspirational;
* the promotions panel asks whether anything has come *back* out of an
* engagement, which is the only evidence that the loop closed at all.
*
* So the absences are promoted rather than hidden. A stage with live
* engagements and no template is the most useful finding this page can carry
* it names the place where the next deployment will be improvised and a
* missing row rendered as a grey zero is a finding nobody reads. Gaps get a
* warning tone, a count and a link that lands on the library already filtered
* to the hole.
*
* Counting is the server's job: `/api/motion` returns all eight stages and all
* nine kinds in ontology order, zeros included, so nothing here zero-fills.
*/
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
ArrowUpRight,
Handshake,
Library,
Lock,
RefreshCw,
Sparkles,
} from 'lucide-react';
import {
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_KIND_LABELS,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionKind,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import { percent } from '@/components/motion/format';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Skeleton,
Stat,
} from '@/components/ui';
import { get, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface KindCoverage {
kind: MotionKind;
templates: number;
shared: number;
}
interface EngagementSummary {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
artifactCount: number;
latestScore: { basisPoints: number; band: string } | null;
}
interface Promotion {
templateId: string;
slug: string;
title: string;
kind: MotionKind;
version: number;
promotedAt: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
totals: {
templates: number;
shared: number;
private: number;
engagements: number;
open: number;
promotions: number;
};
stages: StageCoverage[];
library: KindCoverage[];
engagements: EngagementSummary[];
promotions: Promotion[];
}
// --------------------------------------------------------------------- page
export function Motion() {
usePageTitle('Motion');
usePiggyContext({ type: 'page', route: toPiggyPageRoute('/motion'), label: 'Motion' });
const me = useIdentity();
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Motion is restricted"
description="The library and its engagements sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const data = overview.data;
// A stage carrying live work with nothing in the library behind it. Both
// halves matter: an empty stage with no template is a stage nobody is
// working, which is not a gap in the motion.
const gaps = (data?.stages ?? []).filter(
(stage) => stage.templates === 0 && stage.engagements > 0,
);
const uncovered = (data?.stages ?? []).filter((stage) => stage.templates === 0);
const empty = data ? data.totals.templates === 0 && data.totals.engagements === 0 : false;
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
{overview.isLoading ? <Skeleton className="h-72" /> : null}
{overview.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Motion is unavailable"
description={
overview.error instanceof Error
? overview.error.message
: 'The motion summary could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void overview.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{empty ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title="Nothing in the motion yet"
description="A motion starts as one template. Author the discovery guide you already run from memory, then instantiate it into the next deal."
action={
<Button variant="primary" asChild>
<Link to="/motion/library">Open the library</Link>
</Button>
}
/>
</CardContent>
</Card>
) : null}
{data && !empty ? (
<>
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-4">
<Stat
label="Templates"
value={data.totals.templates}
hint={`${data.totals.shared} shared · ${data.totals.private} private`}
/>
<Stat
label="Open engagements"
value={data.totals.open}
hint={`${data.totals.engagements} recorded in total`}
/>
<Stat
label="Stages covered"
value={`${data.stages.length - uncovered.length}/${data.stages.length}`}
hint={uncovered.length ? 'The library stops short of the motion' : 'Every stage has a template'}
tone={gaps.length ? 'warning' : uncovered.length ? 'default' : 'positive'}
/>
<Stat
label="Promotions"
value={data.totals.promotions}
hint={data.totals.promotions ? 'Artefacts that came back as templates' : 'Nothing has closed the loop yet'}
/>
</section>
<Card>
<CardHeader>
<CardTitle>The motion, stage by stage</CardTitle>
<p className="text-sm text-muted">
Live engagements sit on the deal&rsquo;s stage. The second figure is what the
library has waiting there.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-3">
<StageRail counts={countsByStage(data.stages)} coverage={templatesByStage(data.stages)} />
{gaps.length ? (
<div className="flex min-w-0 gap-3 rounded-lg border border-warning/30 bg-warning/10 p-3 text-sm">
<AlertTriangle className="size-4 shrink-0 text-warning" aria-hidden />
<div className="min-w-0">
<p className="font-medium">
{gaps.length === 1 ? 'One stage is' : `${gaps.length} stages are`} being worked
without a template
</p>
<p className="mt-1 text-muted">
Whatever gets written there this week is written from memory, and the next
deal starts over. Author one from the engagement that is already in it.
</p>
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{gaps.map((gap) => (
<Link
key={gap.stage}
to={`/motion/library?stage=${gap.stage}`}
className="tap inline-flex min-h-11 min-w-0 items-center gap-1 rounded-lg bg-surface px-3 text-sm font-medium underline-offset-4 hover:underline"
>
<span className="truncate">{DEMAND_STAGE_LABELS[gap.stage]}</span>
<span className="nums shrink-0 text-muted">
{gap.engagements} live
</span>
</Link>
))}
</div>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Library cover, by kind</CardTitle>
<p className="text-sm text-muted">
Nine kinds. A kind with nothing in it is a conversation that gets improvised
every time.
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/library">
<Library aria-hidden />
Library
</Link>
</Button>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-2 xl:grid-cols-3">
{data.library.map((kind) => (
<KindCoverageCard key={kind.kind} coverage={kind} />
))}
</CardContent>
</Card>
<div className="grid min-w-0 gap-5 lg:grid-cols-2">
<Card className="min-w-0">
<CardHeader>
<CardTitle>What got easier</CardTitle>
<p className="text-sm text-muted">
Artefacts an engagement proved, promoted back into the library as a new version.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.promotions.length === 0 ? (
<EmptyState
icon={<Sparkles />}
title="Nothing promoted yet"
description="Finalise an artefact in an engagement and promote it. That is the step that makes the next deployment cheaper than this one."
/>
) : (
data.promotions.map((promotion) => (
<PromotionRow key={promotion.templateId} promotion={promotion} />
))
)}
</CardContent>
</Card>
<Card className="min-w-0">
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Open engagements</CardTitle>
<p className="text-sm text-muted">Where the library is being used right now.</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link to="/motion/engagements">
<Handshake aria-hidden />
All
</Link>
</Button>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{data.engagements.length === 0 ? (
<EmptyState
icon={<Handshake />}
title="No open engagements"
description="Open one against a demand deal to file discovery, scoping and proposal work against its stages."
/>
) : (
data.engagements.map((engagement) => (
<EngagementRow key={engagement.id} engagement={engagement} />
))
)}
</CardContent>
</Card>
</div>
</>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">
Go-to-market
</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Motion</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
The repeatable practice behind the ledger: what the library covers, where engagements
actually sit, and what came back out of one.
</p>
</header>
);
}
function KindCoverageCard({ coverage }: { coverage: KindCoverage }) {
const missing = coverage.templates === 0;
return (
<Link
to={`/motion/library?kind=${coverage.kind}`}
className="tap flex min-h-11 min-w-0 flex-col gap-2 rounded-xl border border-border bg-surface-2/60 p-3 hover:bg-surface-2"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<MotionKindBadge kind={coverage.kind} />
{missing ? (
<Badge tone="warning" className="shrink-0">
No template
</Badge>
) : (
<span className="nums shrink-0 text-sm font-semibold">{coverage.templates}</span>
)}
</div>
<p className="min-w-0 line-clamp-2 text-xs leading-5 text-muted">
{missing ? MOTION_KIND_DESCRIPTIONS[coverage.kind] : `${coverage.shared} shared to the book`}
</p>
<span className="sr-only">{MOTION_KIND_LABELS[coverage.kind]}</span>
</Link>
);
}
function PromotionRow({ promotion }: { promotion: Promotion }) {
// The whole row is the target, as it is for `EngagementRow` in the card
// beside it. A one-line text link is about 20px tall on a phone, and two
// identically-shaped rows where only one can be tapped reads as a dead page
// rather than as a smaller hit area.
return (
<Link
to={`/motion/library/${promotion.templateId}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{promotion.title}</p>
<p className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted">
<MotionKindBadge kind={promotion.kind} iconOnly />
<span className="nums whitespace-nowrap">v{promotion.version}</span>
<span className="min-w-0 truncate">
{promotion.dealName ? `from ${promotion.dealName}` : 'from an engagement'}
</span>
</p>
</div>
<span className="nums shrink-0 text-xs text-muted" title={shortDate(promotion.promotedAt)}>
{relativeTime(promotion.promotedAt)}
</span>
</Link>
);
}
function EngagementRow({ engagement }: { engagement: EngagementSummary }) {
return (
<Link
to={`/motion/engagements/${engagement.id}`}
className="tap flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg bg-surface-2 p-3 hover:bg-border/60"
>
<div className="min-w-0">
<p className="min-w-0 truncate font-medium">
{engagement.dealName ?? 'Unnamed deal'}
</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{engagement.accountName ?? 'Unknown account'} ·{' '}
{engagement.stage ? DEMAND_STAGE_LABELS[engagement.stage] : 'No stage'} ·{' '}
<span className="nums">{engagement.artifactCount}</span> artefact
{engagement.artifactCount === 1 ? '' : 's'}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{engagement.latestScore ? (
<Badge tone="neutral" className="nums">
{percent(engagement.latestScore.basisPoints)}
</Badge>
) : null}
<ArrowUpRight className="size-4 text-muted" aria-hidden />
</div>
</Link>
);
}
function countsByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.engagements]));
}
function templatesByStage(stages: StageCoverage[]): Partial<Record<DemandStage, number>> {
return Object.fromEntries(stages.map((stage) => [stage.stage, stage.templates]));
}
+539
View File
@@ -0,0 +1,539 @@
/**
* Every engagement, one per demand deal.
*
* The list answers a different question from the Motion home: not "is the
* motion repeating" but "which deals are being worked with it, and what does
* qualification say about them now". So the columns are the ones that move
* stage, artefact count, latest score and the status filter lives in the URL
* so a view of everything still open can be pasted to somebody.
*
* `Unscored` is a stat rather than a column because it is the finding: an open
* engagement nobody has qualified is a deal being worked on instinct, and it is
* invisible in a table that only shows the scores that exist.
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import {
AlertTriangle,
ChevronRight,
Handshake,
Lock,
Plus,
RefreshCw,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGE_LABELS,
ENGAGEMENT_STATUSES,
ENGAGEMENT_STATUS_LABELS,
isEngagementStatus,
toPiggyPageRoute,
type DemandStage,
type EngagementStatus,
type MotionBandTone,
} from '@pig/core';
import { percent } from '@/components/motion/format';
import { get, post, shortDate } from '@/lib/api';
import { usePageTitle } from '@/lib/title';
import { usePiggyContext } from '@/lib/piggy-context';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import {
Badge,
Button,
Card,
CardContent,
EmptyState,
Skeleton,
Stat,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
/** The score the list carries — the latest one, or none yet. */
interface LatestScore {
basisPoints: number;
band: string;
tone: MotionBandTone;
scoredAt: string;
}
/** `GET /api/motion/engagements` rows. `stage` is the deal's, not the engagement's. */
interface EngagementRow {
id: string;
demandDealId: string;
dealName: string | null;
stage: DemandStage | null;
accountId: string | null;
accountName: string | null;
status: EngagementStatus;
summary: string | null;
openedAt: string;
closedAt: string | null;
artifactCount: number;
latestScore: LatestScore | null;
}
interface DemandDealRow {
deal: { id: string; name: string; stage: DemandStage };
accountName: string | null;
}
const WRITE_DENIED = 'Opening an engagement needs the motion:write permission.';
export function MotionEngagements() {
usePageTitle('Engagements');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/engagements'),
label: 'Engagements',
});
const me = useIdentity();
const mayWrite = canAny(me, 'motion:write');
const [params, setParams] = useSearchParams();
const [opening, setOpening] = useState(false);
// The filter lives in the URL so a view of "everything still open" can be
// pasted to somebody, which is the whole reason anyone filters a list.
const statusParam = params.get('status');
const status = statusParam && isEngagementStatus(statusParam) ? statusParam : null;
const engagements = useQuery({
queryKey: ['motion', 'engagements', { status }],
queryFn: () =>
get<{ engagements: EngagementRow[] }>(
`/api/motion/engagements${status ? `?status=${status}` : ''}`,
),
// The permission short-circuit below is an early return, and a hook cannot
// sit behind one — so without this the read fires for exactly the people
// the page is about to tell they have no access, and react-query retries
// the 403 while they read the empty state.
enabled: canAny(me, 'book:read'),
});
const rows = engagements.data?.engagements ?? [];
const totals = useMemo(
() => ({
open: rows.filter((row) => row.status === 'open').length,
artifacts: rows.reduce((sum, row) => sum + row.artifactCount, 0),
unscored: rows.filter((row) => row.status === 'open' && !row.latestScore).length,
}),
[rows],
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<header>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
</header>
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="Engagements are restricted"
description="Reading the book behind an engagement needs the book permission. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-w-0 flex-col gap-5">
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl">Engagements</h1>
<p className="mt-1 max-w-2xl text-sm text-muted">
One per demand deal: the artefacts it has produced, and what qualification says about
it now rather than when it was opened.
</p>
</div>
<Button
type="button"
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
<Plus aria-hidden />
Open an engagement
</Button>
</header>
{rows.length ? (
<section className="grid grid-cols-2 gap-2 sm:gap-3 xl:grid-cols-3" aria-label="Engagement summary">
<Stat label="Open" value={totals.open} hint="Still in the motion" />
<Stat label="Artefacts" value={totals.artifacts} hint="Across the list below" />
<Stat
label="Unscored"
value={totals.unscored}
hint="Open, never qualified"
tone={totals.unscored ? 'warning' : 'default'}
/>
</section>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusFilter
value={status}
onChange={(next) => {
const updated = new URLSearchParams(params);
if (next) updated.set('status', next);
else updated.delete('status');
setParams(updated, { replace: true });
}}
/>
</div>
{engagements.isLoading ? <Skeleton className="h-72" /> : null}
{engagements.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Engagements unavailable"
description={
engagements.error instanceof Error
? engagements.error.message
: 'The list could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void engagements.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{!engagements.isLoading && !engagements.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Handshake />}
title={status ? 'No engagement in this state' : 'No engagements yet'}
description={
status
? 'Clear the filter to see the rest of them.'
: 'An engagement binds the library to a demand deal. Open one on the deal you are working now, and the artefacts it produces can be promoted back.'
}
action={
status ? undefined : (
<Button
variant="primary"
disabled={!mayWrite}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => setOpening(true)}
>
Open the first one
</Button>
)
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<>
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Deal</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Status</TableHead>
<TableHead>Artefacts</TableHead>
<TableHead>Qualification</TableHead>
<TableHead>Opened</TableHead>
<TableHead className="w-12">
<span className="sr-only">Open</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="min-w-0">
<Link
to={`/motion/engagements/${row.id}`}
className="font-medium underline-offset-4 hover:text-accent-fg hover:underline"
>
{row.dealName ?? 'Untitled deal'}
</Link>
<p className="truncate text-xs text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
</TableCell>
<TableCell className="text-sm">
{row.stage ? DEMAND_STAGE_LABELS[row.stage] : '—'}
</TableCell>
<TableCell>
<StatusBadge status={row.status} />
</TableCell>
<TableCell className="nums text-sm">{row.artifactCount}</TableCell>
<TableCell>
<ScoreCell score={row.latestScore} />
</TableCell>
<TableCell className="nums text-sm">{shortDate(row.openedAt)}</TableCell>
<TableCell>
<Link
to={`/motion/engagements/${row.id}`}
className="tap inline-flex items-center justify-center text-muted"
aria-label={`Open ${row.dealName ?? 'engagement'}`}
>
<ChevronRight aria-hidden />
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
<div className="grid gap-3 md:hidden">
{rows.map((row) => (
<Link
key={row.id}
to={`/motion/engagements/${row.id}`}
className="card min-h-11 min-w-0 p-4"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<StatusBadge status={row.status} />
{row.stage ? (
<Badge tone="neutral" className="min-w-0">
<span className="truncate">{DEMAND_STAGE_LABELS[row.stage]}</span>
</Badge>
) : null}
</div>
<p className="mt-2 truncate font-medium">{row.dealName ?? 'Untitled deal'}</p>
<p className="mt-0.5 truncate text-sm text-muted">
{row.accountName ?? 'Unknown account'}
</p>
</div>
<ChevronRight className="shrink-0 text-muted" aria-hidden />
</div>
<div className="mt-3 flex min-w-0 items-end justify-between gap-3 border-t border-border pt-3 text-xs text-muted">
<p className="nums min-w-0">
{row.artifactCount} artifact{row.artifactCount === 1 ? '' : 's'} · opened{' '}
{shortDate(row.openedAt)}
</p>
<ScoreCell score={row.latestScore} />
</div>
</Link>
))}
</div>
</>
) : null}
<OpenEngagementSheet open={opening} onOpenChange={setOpening} />
</div>
);
}
/**
* Opening an engagement on a deal that already has one is a 409, so the deals
* that already have one are not offered. The server still refuses this is a
* courtesy, not the rule but an option that can only fail is a worse thing
* to ship than a shorter list.
*/
function OpenEngagementSheet({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
// Its own check rather than a prop. The trigger is already disabled without
// `motion:write`, but these two reads are what the sheet costs, and a query
// that fires for somebody who cannot use the result is a 403 in the network
// tab and a retry loop behind a dialog they were never meant to open.
const mayWrite = canAny(useIdentity(), 'motion:write');
const [demandDealId, setDemandDealId] = useState('');
const [summary, setSummary] = useState('');
const deals = useQuery({
queryKey: ['deals', 'demand'],
queryFn: () => get<{ deals: DemandDealRow[] }>('/api/deals/demand'),
enabled: open && mayWrite,
});
/*
* Its own unfiltered query rather than the rows the page is showing. The
* status filter is the trap: filtered to "Won", the page holds none of the
* open engagements, so every deal already running would be offered here and
* every one of them would answer 409. The key matches the page's own
* unfiltered query, so the two share a cache entry rather than racing.
*/
const allEngagements = useQuery({
queryKey: ['motion', 'engagements', { status: null }],
queryFn: () => get<{ engagements: EngagementRow[] }>('/api/motion/engagements'),
enabled: open && mayWrite,
});
const taken = new Set((allEngagements.data?.engagements ?? []).map((row) => row.demandDealId));
const available = (deals.data?.deals ?? []).filter((row) => !taken.has(row.deal.id));
const create = useMutation({
mutationFn: () =>
post<{ engagement: { id: string } }>('/api/motion/engagements', {
demandDealId,
summary: summary.trim() ? summary.trim() : null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['motion'] });
toast.success('Engagement opened');
setDemandDealId('');
setSummary('');
onOpenChange(false);
},
onError: (error: Error) => toast.error(error.message),
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-lg">
<SheetHeader>
<SheetTitle>Open an engagement</SheetTitle>
<SheetDescription>
An engagement hangs off a demand deal rather than replacing it the deal keeps its
stage, and the engagement collects what the motion produces against it.
</SheetDescription>
</SheetHeader>
<form
className="mt-5 flex min-w-0 flex-col gap-4 pb-6"
onSubmit={(event) => {
event.preventDefault();
create.mutate();
}}
>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-deal">Demand deal</Label>
<Select value={demandDealId || undefined} onValueChange={setDemandDealId}>
<SelectTrigger id="engagement-deal" aria-label="Demand deal" className="h-11 min-w-0">
<SelectValue placeholder={deals.isLoading || allEngagements.isLoading ? 'Loading deals…' : 'Choose a deal'} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{available.map((row) => (
<SelectItem key={row.deal.id} value={row.deal.id}>
{row.deal.name} · {row.accountName ?? 'Unknown account'}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
{!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.
</p>
) : null}
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="engagement-summary">What this engagement is for</Label>
<Textarea
id="engagement-summary"
className="min-h-24"
value={summary}
onChange={(event) => setSummary(event.target.value)}
placeholder="The deployment being scoped, in one or two sentences."
/>
</div>
{create.isError ? <p className="text-sm text-danger">{create.error.message}</p> : null}
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={!demandDealId || create.isPending}>
Open engagement
</Button>
</div>
</form>
</SheetContent>
</Sheet>
);
}
// ------------------------------------------------------------------ helpers
function StatusFilter({
value,
onChange,
}: {
value: EngagementStatus | null;
onChange: (value: EngagementStatus | null) => void;
}) {
return (
<div role="tablist" aria-label="Filter by status" className="scroll-x -mx-1 flex gap-1 px-1 pb-1">
{([null, ...ENGAGEMENT_STATUSES] as const).map((option) => (
<button
key={option ?? 'all'}
type="button"
role="tab"
aria-selected={value === option}
onClick={() => onChange(option)}
className={cn(
'tap min-h-11 shrink-0 rounded-lg px-4 text-sm font-medium transition-colors',
value === option
? 'bg-surface text-fg shadow-sm ring-1 ring-border'
: 'text-muted hover:bg-surface-2',
)}
>
{option ? ENGAGEMENT_STATUS_LABELS[option] : 'All'}
</button>
))}
</div>
);
}
function StatusBadge({ status }: { status: EngagementStatus }) {
const tone =
status === 'won' ? 'positive' : status === 'lost' ? 'danger' : status === 'paused' ? 'warning' : 'info';
return <Badge tone={tone}>{ENGAGEMENT_STATUS_LABELS[status]}</Badge>;
}
/**
* The band leads and the figure follows it.
*
* "Qualified" is what a seller acts on; 6,200 basis points is what an analyst
* checks afterwards. Showing the number alone invites the reading that 62 is
* nearly 70 and therefore nearly good, when the band boundary at 7500 is the
* only place anything changes.
*/
function ScoreCell({ score }: { score: LatestScore | null }) {
if (!score) return <span className="text-sm text-muted">Not scored</span>;
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-1.5">
<Badge tone={score.tone}>{score.band}</Badge>
<span className="nums whitespace-nowrap text-xs text-muted" title={`${score.basisPoints} basis points`}>
{percent(score.basisPoints)}
</span>
</span>
);
}
+420
View File
@@ -0,0 +1,420 @@
/**
* The library: nine kinds, filtered four ways, in a URL somebody can send.
*
* The filters live in the query string rather than in component state, which is
* a first for this app. It is not tidiness "the proposal blocks we have for
* procurement" is a thing one person tells another, and until now the only way
* to share a view was to describe the clicks. `useSearchParams` makes the view
* the address.
*
* Two rules keep that URL readable, and both are load-bearing:
* defaults are never written (an unset filter is an absent parameter, not
* `?kind=all`), and every write is `replace`, so dragging across the stage rail
* leaves one history entry rather than eight for the back button to walk.
*
* An unrecognised value is treated as absent. The API answers `400
* invalid_request` on an unknown enum, and a hand-edited URL — or one that
* outlived a kind the ontology dropped should degrade to the unfiltered
* library, not to an error page.
*
* The rail's figures come from `/api/motion` rather than from the rows on
* screen: a rail computed from the filtered result empties as you use it, which
* makes the one control that is meant to navigate the motion useless the moment
* anything is selected.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { AlertTriangle, Layers, Library, Lock, RefreshCw, Search } from 'lucide-react';
import {
DEMAND_STAGES,
MOTION_KINDS,
MOTION_KIND_LABELS,
MOTION_VISIBILITIES,
MOTION_VISIBILITY_LABELS,
isMotionKind,
isMotionVisibility,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { StageRail } from '@/components/motion/StageRail';
import { TemplateCard } from '@/components/motion/TemplateCard';
import type { MotionTemplateView } from '@/components/motion/model';
import { Button, Card, CardContent, EmptyState, Input, Skeleton } from '@/components/ui';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { get } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
/**
* What the list endpoint serialises: the row without `body` or `fields`.
*
* Two hundred playbooks is a megabyte of markdown nobody on this page reads, so
* the API sends the summary and the detail endpoint sends the document.
*/
type TemplateSummary = Omit<MotionTemplateView, 'body' | 'fields'>;
interface TemplateList {
templates: TemplateSummary[];
/** The scan hit its bound — the library is wider than this answer. */
truncated: boolean;
}
interface StageCoverage {
stage: DemandStage;
engagements: number;
templates: number;
}
interface MotionOverview {
stages: StageCoverage[];
}
interface Filters {
kind: MotionKind | null;
stage: DemandStage | null;
visibility: MotionVisibility | null;
q: string;
all: boolean;
}
const STAGE_PARAM = 'stage';
/** Long enough that a typed word is one request, short enough to feel live. */
const SEARCH_SETTLE_MS = 250;
// --------------------------------------------------------------------- page
export function MotionLibrary() {
usePageTitle('Library');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: 'Motion library',
});
const me = useIdentity();
const [params, setParams] = useSearchParams();
const filters = readFilters(params);
// The input is local and the URL follows it, rather than the other way
// round: bound directly to the query string, every keystroke is a request
// against a 400-row scan and a URL that is only copyable between words.
const [text, setText] = useState(filters.q);
const pushed = useRef(filters.q);
useEffect(() => {
if (text === filters.q) return;
const timer = setTimeout(() => {
pushed.current = text;
setParam(setParams, 'q', text);
}, SEARCH_SETTLE_MS);
return () => clearTimeout(timer);
}, [filters.q, setParams, text]);
useEffect(() => {
// The URL moved and it was not us — a pasted link, or a back navigation
// out of some other filter. Adopt it. Without this the pending input wins
// the next tick and pushes itself straight back over the address, which
// reads as a back button that does not work.
if (filters.q === pushed.current) return;
pushed.current = filters.q;
setText(filters.q);
}, [filters.q]);
const templates = useQuery({
queryKey: ['motion', 'templates', filters],
queryFn: () => get<TemplateList>(`/api/motion/templates${queryString(filters)}`),
enabled: canAny(me, 'book:read'),
});
// Shared with the Motion home, and deliberately unfiltered — see the header.
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: canAny(me, 'book:read'),
});
const railCounts = useMemo(
() =>
Object.fromEntries(
(overview.data?.stages ?? []).map((stage) => [stage.stage, stage.templates]),
) as Partial<Record<DemandStage, number>>,
[overview.data],
);
const filtered = Boolean(
filters.kind || filters.stage || filters.visibility || filters.q || filters.all,
);
if (!canAny(me, 'book:read')) {
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Lock />}
title="The library is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
</CardContent>
</Card>
</div>
);
}
const rows = templates.data?.templates ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<PageHeader />
<StageRail
counts={railCounts}
countLabel="templates"
activeStage={filters.stage}
onSelect={(stage) =>
setParam(setParams, STAGE_PARAM, stage === filters.stage ? '' : stage)
}
/>
<div className="grid min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 sm:grid-cols-[minmax(0,1fr)_170px_170px]">
<div className="relative min-w-0">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted" aria-hidden />
<Input
aria-label="Search the library"
className="pl-9"
placeholder="Search title, summary or slug"
value={text}
onChange={(event) => setText(event.target.value)}
/>
</div>
<EnumSelect
aria-label="Filter by kind"
value={filters.kind ?? 'all'}
onValueChange={(value) => setParam(setParams, 'kind', value === 'all' ? '' : value)}
>
<SelectItem value="all">All kinds</SelectItem>
{MOTION_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{MOTION_KIND_LABELS[kind]}
</SelectItem>
))}
</EnumSelect>
<EnumSelect
aria-label="Filter by visibility"
value={filters.visibility ?? 'all'}
onValueChange={(value) => setParam(setParams, 'visibility', value === 'all' ? '' : value)}
>
<SelectItem value="all">Shared and private</SelectItem>
{MOTION_VISIBILITIES.map((visibility) => (
<SelectItem key={visibility} value={visibility}>
{MOTION_VISIBILITY_LABELS[visibility]}
</SelectItem>
))}
</EnumSelect>
</div>
<div className="flex min-h-11 min-w-0 flex-wrap items-center justify-between gap-2">
<p className="min-w-0 text-sm text-muted">
<strong className="nums text-fg">{rows.length}</strong>{' '}
{filters.all ? 'versions' : 'templates'} shown
{templates.data?.truncated ? ' · more exist than fit in one answer' : ''}
</p>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{/* Lineages collapse to their newest version by default. The whole
history is what you want when judging whether a template is
settled or still being rewritten every fortnight. */}
<Button
variant={filters.all ? 'secondary' : 'ghost'}
size="sm"
aria-pressed={filters.all}
onClick={() => setParam(setParams, 'all', filters.all ? '' : '1')}
>
<Layers aria-hidden />
{filters.all ? 'Newest versions' : 'Every version'}
</Button>
{filtered ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : null}
</div>
</div>
{templates.isLoading ? <Skeleton className="h-64" /> : null}
{templates.isError ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Library unavailable"
description={
templates.error instanceof Error
? templates.error.message
: 'The library could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void templates.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
) : null}
{!templates.isLoading && !templates.isError && rows.length === 0 ? (
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<Library />}
title={filtered ? 'No templates match' : 'The library is empty'}
description={
filtered
? 'Clear a filter to see the rest of the library. A private template belonging to somebody else will never appear here.'
: 'Nine kinds and nothing in them yet. Author the discovery guide you already run from memory, and instantiate it into the next deal.'
}
action={
filtered ? (
<Button
variant="outline"
onClick={() => {
setText('');
setParams(new URLSearchParams(), { replace: true });
}}
>
Clear filters
</Button>
) : undefined
}
/>
</CardContent>
</Card>
) : null}
{rows.length ? (
<div className="grid min-w-0 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{rows.map((template) => (
<TemplateCard
key={template.id}
// The card reads neither `body` nor `fields`; its prop type asks
// for them only because one interface describes both endpoints.
// Asserting beats inventing an empty body, which would be
// indistinguishable from a template somebody saved blank.
template={template as MotionTemplateView}
/>
))}
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------- fragments
function PageHeader() {
return (
<header className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-accent-fg">Motion</p>
<h1 className="mt-1 text-xl font-semibold tracking-tight sm:text-2xl">Library</h1>
<p className="mt-1 max-w-2xl text-sm leading-6 text-muted">
Discovery guides, qualification frameworks, proposal blocks and playbooks the parts of a
deployment that should not be rewritten each time. This view is linkable: send the address,
not the clicks.
</p>
</header>
);
}
type EnumSelectProps = React.ComponentProps<typeof Select> & { 'aria-label': string };
function EnumSelect({ children, 'aria-label': ariaLabel, ...props }: EnumSelectProps) {
return (
<Select {...props}>
<SelectTrigger aria-label={ariaLabel} className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>{children}</SelectGroup>
</SelectContent>
</Select>
);
}
// ------------------------------------------------------------------ filters
/**
* Local, because `@pig/core` ships guards for the motion enums but not for the
* stage list. Declared here rather than added there: this is the only caller,
* and widening the ontology's surface for one URL parser is not the trade.
*/
function isDemandStage(value: string): value is DemandStage {
return (DEMAND_STAGES as readonly string[]).includes(value);
}
/** An unrecognised value reads as absent — see the header. */
function readFilters(params: URLSearchParams): Filters {
const kind = params.get('kind');
const stage = params.get(STAGE_PARAM);
const visibility = params.get('visibility');
return {
kind: kind && isMotionKind(kind) ? kind : null,
stage: stage && isDemandStage(stage) ? stage : null,
visibility: visibility && isMotionVisibility(visibility) ? visibility : null,
q: params.get('q')?.trim() ?? '',
all: params.get('all') === '1',
};
}
/**
* Set one parameter, dropping it when it falls back to the default.
*
* Functional form, because two filters can settle within one tick the search
* debounce firing while a stage is being pressed and reading `params` from
* the closure would make the second write discard the first.
*/
function setParam(
setParams: ReturnType<typeof useSearchParams>[1],
key: string,
value: string,
): void {
setParams(
(current) => {
const next = new URLSearchParams(current);
if (value) next.set(key, value);
else next.delete(key);
return next;
},
{ replace: true },
);
}
function queryString(filters: Filters): string {
const query = new URLSearchParams();
if (filters.kind) query.set('kind', filters.kind);
if (filters.stage) query.set(STAGE_PARAM, filters.stage);
if (filters.visibility) query.set('visibility', filters.visibility);
if (filters.q) query.set('q', filters.q);
if (filters.all) query.set('all', '1');
const rendered = query.toString();
return rendered ? `?${rendered}` : '';
}
+729
View File
@@ -0,0 +1,729 @@
/**
* One template, with its provenance stated rather than implied.
*
* The document is the obvious half of this page and the least interesting one.
* The argument for the whole feature is the chain: this is v3, it supersedes
* v2, and it exists because an artefact in a real engagement was finalised and
* promoted. A library that shows only the newest body is a folder of files
* the lineage is what makes "every deployment makes the next one easier" a
* mechanism somebody can audit.
*
* The one rule this page has to teach is §7a: a template that has been
* instantiated is never edited in place, because a live engagement must not
* have the thing it was copied from change underneath it. So a used template
* does not show a dead Save button it says what the rule is and offers the
* move that is actually available, a new version in the same lineage.
*
* `canEdit` and `canPublish` are the server's judgement, read off the detail
* response. Re-deriving them here from `usageCount` and ownership would put a
* second copy of the rule in TSX, and the two copies would drift.
*/
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
GitBranch,
History,
Lock,
Pencil,
RefreshCw,
Send,
Sparkles,
Users,
} from 'lucide-react';
import { toast } from 'sonner';
import {
DEMAND_STAGES,
DEMAND_STAGE_LABELS,
MOTION_KIND_DESCRIPTIONS,
MOTION_VISIBILITY_LABELS,
toPiggyPageRoute,
type DemandStage,
type MotionKind,
type MotionVisibility,
} from '@pig/core';
import { FieldsView } from '@/components/motion/FieldsView';
import { Markdown } from '@/components/motion/Markdown';
import { MotionKindBadge } from '@/components/motion/MotionKindBadge';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
EmptyState,
Input,
Skeleton,
cn,
} from '@/components/ui';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { ApiError, get, patch, post, relativeTime, shortDate } from '@/lib/api';
import { useIdentity } from '@/lib/identity';
import { canAny } from '@/lib/permissions';
import { usePiggyContext } from '@/lib/piggy-context';
import { usePageTitle } from '@/lib/title';
// ------------------------------------------------------------------- shapes
interface TemplateSummary {
id: string;
kind: MotionKind;
slug: string;
version: number;
title: string;
summary: string;
stage: DemandStage;
visibility: MotionVisibility;
ownerUserId: string | null;
supersedesId: string | null;
originArtifactId: string | null;
isSystem: boolean;
usageCount: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
interface TemplateDetail extends TemplateSummary {
body: string;
fields: Record<string, unknown> | null;
}
interface TemplateDetailResponse {
template: TemplateDetail;
/** Every version of this slug the reader may see, oldest first. */
lineage: TemplateSummary[];
/** Owner or admin, and never instantiated. The §7a rule, already applied. */
canEdit: boolean;
canPublish: boolean;
}
interface EngagementOption {
id: string;
dealName: string | null;
accountName: string | null;
stage: DemandStage | null;
}
interface Promotion {
templateId: string;
engagementId: string | null;
dealName: string | null;
}
interface MotionOverview {
promotions: Promotion[];
}
interface EditState {
title: string;
summary: string;
body: string;
stage: DemandStage;
}
// --------------------------------------------------------------------- page
export function MotionTemplate() {
const { id = '' } = useParams<{ id: string }>();
const me = useIdentity();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [editing, setEditing] = useState(false);
const [engagementId, setEngagementId] = useState('');
const detail = useQuery({
queryKey: ['motion', 'templates', id, 'detail'],
queryFn: () => get<TemplateDetailResponse>(`/api/motion/templates/${id}`),
enabled: Boolean(id) && canAny(me, 'book:read'),
retry: false,
});
const engagements = useQuery({
queryKey: ['motion', 'engagements', 'open'],
queryFn: () => get<{ engagements: EngagementOption[] }>('/api/motion/engagements?status=open'),
enabled: Boolean(id) && canAny(me, 'motion:write'),
});
/*
* Read only for the provenance line. The detail response carries
* `originArtifactId` but not the engagement behind it, and the overview's
* promotion list is the one place that join already exists so a recently
* promoted template can name the deal it came out of, and an older one says
* plainly that it came from an engagement without inventing which.
*/
const overview = useQuery({
queryKey: ['motion', 'overview'],
queryFn: () => get<MotionOverview>('/api/motion'),
enabled: Boolean(detail.data?.template.originArtifactId),
});
const template = detail.data?.template;
usePageTitle(template?.title ?? 'Template');
usePiggyContext({
type: 'page',
route: toPiggyPageRoute('/motion/library'),
label: template ? template.title : 'Motion library',
});
const invalidate = async (): Promise<void> => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['motion', 'templates'] }),
queryClient.invalidateQueries({ queryKey: ['motion', 'overview'] }),
]);
};
const save = useMutation({
mutationFn: (input: EditState) => patch<{ template: TemplateDetail }>(`/api/motion/templates/${id}`, input),
onSuccess: async () => {
setEditing(false);
await invalidate();
toast.success('Template saved');
},
onError: (error: Error) => toast.error(error.message),
});
const fork = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/versions`, {}),
onSuccess: async ({ template: created }) => {
await invalidate();
toast.success(`Version ${created.version} drafted, private to you`);
navigate(`/motion/library/${created.id}`);
},
onError: (error: Error) => toast.error(error.message),
});
const publish = useMutation({
mutationFn: () => post<{ template: TemplateDetail }>(`/api/motion/templates/${id}/publish`, {}),
onSuccess: async () => {
await invalidate();
toast.success('Published to the book');
},
onError: (error: Error) => toast.error(error.message),
});
const instantiate = useMutation({
mutationFn: (target: string) =>
post<{ artifact: { id: string; engagementId: string } }>(
`/api/motion/engagements/${target}/artifacts`,
{ templateId: id },
),
onSuccess: async ({ artifact }) => {
await invalidate();
await queryClient.invalidateQueries({ queryKey: ['motion', 'engagements'] });
toast.success('Instantiated into the engagement');
navigate(`/motion/engagements/${artifact.engagementId}`);
},
onError: (error: Error) => toast.error(error.message),
});
if (!canAny(me, 'book:read')) {
return (
<Restricted
icon={<Lock />}
title="This template is restricted"
description="Motion templates sit behind book access. Ask a platform administrator for team membership."
/>
);
}
if (detail.isLoading) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Skeleton className="h-96" />
</div>
);
}
if (detail.error instanceof ApiError && detail.error.status === 404) {
return (
<Restricted
icon={<Lock />}
title="No such template"
// The same answer covers both cases on purpose, and saying so is
// kinder than a bare "not found" that reads as a broken link.
description="It has been archived, the link was to an id that never existed, or it is private to somebody else."
/>
);
}
if (detail.error || !detail.data || !template) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState
icon={<AlertTriangle />}
title="Template unavailable"
description={
detail.error instanceof Error ? detail.error.message : 'The template could not be loaded.'
}
action={
<Button variant="outline" onClick={() => void detail.refetch()}>
<RefreshCw aria-hidden />
Try again
</Button>
}
/>
</CardContent>
</Card>
</div>
);
}
const { lineage, canEdit, canPublish } = detail.data;
const mayWrite = canAny(me, 'motion:write');
const supersedes = lineage.find((version) => version.id === template.supersedesId) ?? null;
const promotion = overview.data?.promotions.find((row) => row.templateId === template.id) ?? null;
const openEngagements = engagements.data?.engagements ?? [];
return (
<div className="flex min-w-0 flex-col gap-5">
<BackLink />
<header className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<MotionKindBadge kind={template.kind} />
<Badge tone="neutral">{DEMAND_STAGE_LABELS[template.stage]}</Badge>
<Badge tone={template.visibility === 'private' ? 'warning' : 'neutral'}>
{MOTION_VISIBILITY_LABELS[template.visibility]}
</Badge>
{template.isSystem ? (
<Badge tone="neutral" title="Shipped with PIG rather than authored here">
<Sparkles className="size-3 shrink-0" aria-hidden />
Starter
</Badge>
) : null}
</div>
<h1 className="mt-2 min-w-0 break-words text-xl font-semibold tracking-tight sm:text-2xl">
{template.title}
</h1>
<p className="mt-1 max-w-2xl min-w-0 break-words text-sm leading-6 text-muted">
{template.summary}
</p>
<p className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted">
<span className="nums whitespace-nowrap">Version {template.version}</span>
<span className="nums inline-flex items-center gap-1 whitespace-nowrap">
<Users className="size-3 shrink-0" aria-hidden />
{template.usageCount === 1 ? 'Used once' : `Used ${template.usageCount} times`}
</span>
<span className="min-w-0 truncate">{MOTION_KIND_DESCRIPTIONS[template.kind]}</span>
</p>
</header>
<Provenance
template={template}
supersedes={supersedes}
engagementId={promotion?.engagementId ?? null}
dealName={promotion?.dealName ?? null}
/>
{/*
The §7a rule, said in full where the edit button would otherwise be.
A disabled control with no explanation teaches the reader that the app
is broken; this one names the reason and the move that replaces it.
*/}
{template.usageCount > 0 ? (
<div className="flex min-w-0 gap-3 rounded-xl border border-info/30 bg-info/10 p-3 text-sm">
<History className="size-4 shrink-0 text-info" aria-hidden />
<div className="min-w-0">
<p className="font-medium">This template is closed to edits</p>
<p className="mt-1 text-muted">
{template.usageCount === 1 ? 'An engagement has' : `${template.usageCount} engagements have`}{' '}
already instantiated it, and their artefacts record that they came from v
{template.version}. Changing it now would rewrite their provenance. Draft a new
version instead the lineage keeps both.
</p>
</div>
</div>
) : null}
<div className="grid min-w-0 gap-2 sm:flex sm:flex-wrap">
{canEdit ? (
<Button
variant={editing ? 'secondary' : 'outline'}
aria-pressed={editing}
onClick={() => setEditing((current) => !current)}
>
<Pencil aria-hidden />
{editing ? 'Stop editing' : 'Edit draft'}
</Button>
) : null}
<Button
variant={template.usageCount > 0 ? 'primary' : 'outline'}
disabled={!mayWrite || fork.isPending}
title={mayWrite ? undefined : WRITE_DENIED}
onClick={() => fork.mutate()}
>
<GitBranch aria-hidden />
{template.usageCount > 0 ? 'New version' : 'Fork to a private draft'}
</Button>
{canPublish ? (
<Button variant="primary" disabled={publish.isPending} onClick={() => publish.mutate()}>
<Send aria-hidden />
Publish to the book
</Button>
) : null}
</div>
{editing && canEdit ? (
<TemplateEditor
initial={{
title: template.title,
summary: template.summary,
body: template.body,
stage: template.stage,
}}
pending={save.isPending}
onCancel={() => setEditing(false)}
onSave={(input) => save.mutate(input)}
/>
) : null}
<Card>
<CardHeader className="flex-row items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle>Use this template</CardTitle>
<p className="text-sm text-muted">
Instantiating copies the body and fields into the engagement as a draft artefact, and
records which version it came from.
</p>
</div>
</CardHeader>
<CardContent className="grid min-w-0 gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
{!mayWrite ? (
<p className="text-sm text-muted">{WRITE_DENIED}</p>
) : openEngagements.length === 0 ? (
<p className="text-sm text-muted">
No open engagement to instantiate into.{' '}
<Link to="/motion/engagements" className="underline underline-offset-4">
Open one against a demand deal
</Link>{' '}
first.
</p>
) : (
<>
<Select value={engagementId || undefined} onValueChange={setEngagementId}>
<SelectTrigger aria-label="Choose an engagement" className="h-11 min-w-0">
<SelectValue placeholder="Choose an engagement" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{openEngagements.map((engagement) => (
<SelectItem key={engagement.id} value={engagement.id}>
{engagement.dealName ?? 'Unnamed deal'}
{engagement.accountName ? ` · ${engagement.accountName}` : ''}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<Button
variant="primary"
disabled={!engagementId || instantiate.isPending}
onClick={() => instantiate.mutate(engagementId)}
>
Instantiate
</Button>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>The document</CardTitle>
</CardHeader>
<CardContent>
{template.body.trim() ? (
<Markdown content={template.body} />
) : (
<p className="text-sm text-muted">This version has no body.</p>
)}
</CardContent>
</Card>
<FieldsView kind={template.kind} fields={template.fields} />
<Lineage lineage={lineage} currentId={template.id} slug={template.slug} />
</div>
);
}
// ---------------------------------------------------------------- fragments
const WRITE_DENIED = 'Authoring in the library needs the motion:write permission.';
function BackLink() {
return (
<Link
to="/motion/library"
className="tap inline-flex min-h-11 w-fit items-center gap-2 text-sm text-muted hover:text-fg"
>
<ArrowLeft className="size-4" aria-hidden />
Back to the library
</Link>
);
}
function Restricted({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="flex min-w-0 flex-col gap-4">
<BackLink />
<Card>
<CardContent className="pt-5">
<EmptyState icon={icon} title={title} description={description} />
</CardContent>
</Card>
</div>
);
}
/**
* Where this version came from, in one sentence a reader can act on.
*
* Three facts, and each is absent for a legitimate reason: v1 supersedes
* nothing, an authored template was never promoted, and a promotion older than
* the overview's window is known to exist without its engagement being named.
*/
function Provenance({
template,
supersedes,
engagementId,
dealName,
}: {
template: TemplateSummary;
supersedes: TemplateSummary | null;
engagementId: string | null;
dealName: string | null;
}) {
const promoted = Boolean(template.originArtifactId);
if (!supersedes && !promoted) return null;
return (
<div className="flex min-w-0 gap-3 rounded-xl border border-border bg-surface-2/60 p-3 text-sm">
<GitBranch className="size-4 shrink-0 text-muted" aria-hidden />
<p className="min-w-0 break-words">
<span className="nums font-medium">Version {template.version}</span>
{supersedes ? (
<>
, superseding{' '}
<Link
to={`/motion/library/${supersedes.id}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
v{supersedes.version}
</Link>
</>
) : null}
{promoted ? (
<>
, promoted from an artefact
{engagementId ? (
<>
{' '}on{' '}
<Link
to={`/motion/engagements/${engagementId}`}
className="underline underline-offset-4 hover:text-accent-fg"
>
{dealName ?? 'its engagement'}
</Link>
</>
) : (
' proved in an engagement'
)}
</>
) : null}
. <span className="text-muted">Created {shortDate(template.createdAt)}.</span>
</p>
</div>
);
}
function Lineage({
lineage,
currentId,
slug,
}: {
lineage: TemplateSummary[];
currentId: string;
slug: string;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Version history</CardTitle>
<p className="text-sm text-muted">
Every version of <span className="font-mono text-xs">{slug}</span> you can see, oldest
first. Versions private to somebody else are not listed.
</p>
</CardHeader>
<CardContent className="flex min-w-0 flex-col gap-2">
{lineage.map((version) => (
<LineageRow
key={version.id}
version={version}
current={version.id === currentId}
/>
))}
</CardContent>
</Card>
);
}
/**
* One version in the history.
*
* The whole row navigates rather than the title alone, which was a single line
* of text and about 20px of target on a phone. The version already open is a
* plain div instead: a link to where you already are is a tap that does
* nothing, which is worse than no target at all.
*/
function LineageRow({ version, current }: { version: TemplateSummary; current: boolean }) {
const body = (
<>
<div className="min-w-0">
<p className="min-w-0 break-words font-medium">{version.title}</p>
<p className="mt-1 min-w-0 truncate text-xs text-muted">
{MOTION_VISIBILITY_LABELS[version.visibility]} ·{' '}
<span className="nums">{version.usageCount}</span> use
{version.usageCount === 1 ? '' : 's'} · {relativeTime(version.updatedAt)}
</p>
</div>
<Badge tone={current ? 'accent' : 'neutral'} className="nums shrink-0">
v{version.version}
</Badge>
</>
);
const shape = 'flex min-h-11 min-w-0 items-start justify-between gap-3 rounded-lg p-3';
if (current) {
return <div className={cn(shape, 'bg-surface-2 ring-1 ring-border')}>{body}</div>;
}
return (
<Link
to={`/motion/library/${version.id}`}
className={cn(shape, 'tap bg-surface-2/60 hover:bg-surface-2')}
>
{body}
</Link>
);
}
/**
* The draft editor, shown only while `canEdit` holds.
*
* No `kind` and no `visibility` field, matching the endpoint: a lineage that
* changes kind halfway is a different template wearing the same slug, and
* visibility moves through Publish, where the capability check lives.
*/
function TemplateEditor({
initial,
pending,
onCancel,
onSave,
}: {
initial: EditState;
pending: boolean;
onCancel(): void;
onSave(input: EditState): void;
}) {
const [form, setForm] = useState(initial);
const set = <Key extends keyof EditState>(key: Key, value: EditState[Key]) =>
setForm((current) => ({ ...current, [key]: value }));
return (
<Card>
<CardContent className="pt-5">
<form
className="flex min-w-0 flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
onSave(form);
}}
>
<div className="grid min-w-0 gap-3 sm:grid-cols-2">
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-title">Title</Label>
<Input
id="template-title"
required
value={form.title}
onChange={(event) => set('title', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-stage">Stage it serves</Label>
<Select value={form.stage} onValueChange={(value) => set('stage', value as DemandStage)}>
<SelectTrigger id="template-stage" className="h-11 min-w-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{DEMAND_STAGES.map((stage) => (
<SelectItem key={stage} value={stage}>
{DEMAND_STAGE_LABELS[stage]}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-summary">Summary</Label>
<Textarea
id="template-summary"
required
rows={2}
value={form.summary}
onChange={(event) => set('summary', event.target.value)}
/>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<Label htmlFor="template-body">Body</Label>
<Textarea
id="template-body"
rows={16}
className="font-mono text-xs"
value={form.body}
onChange={(event) => set('body', event.target.value)}
/>
<p className="text-xs text-muted">
Markdown, with GFM tables and task lists. Structured fields are edited through the
API for now.
</p>
</div>
<div className="flex min-w-0 flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={pending}>
Save draft
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
+228
View File
@@ -0,0 +1,228 @@
# Motion
Why the go-to-market half of PIG is shaped the way it is. Read
`packages/db/src/schema/motion.ts` and `apps/api/src/services/motion.ts`
alongside this — the code carries the same reasoning in comments, and it is the
version that cannot go stale.
## The gap this fills
The ledger answers *which contracted capacity is sold, to whom, at what margin*.
It says nothing about the **motion** — the repeatable practice that turns a
messy customer conversation into a scoped deployment, and turns that deployment
into something the next one reuses. A company whose second post-training
engagement is as expensive as its first does not have a product motion; it has
a sequence of heroics.
Motion is deliberately **not a parallel entity tree**. `DEMAND_STAGES` in
`@pig/core` already *is* the motion — `qualification → legal → scoping →
proposal → procurement → poc → deployment → expansion` — so Motion binds
reusable artefacts to the stages of a demand deal that already exists. An
engagement has no independent life: it hangs off one `demand_deal`, cascade
deleted, one per deal by unique constraint.
## Nine kinds, each answering a stage
`MOTION_KINDS` in `packages/core/src/motion.ts` is a closed list, and each kind
declares in `MOTION_KIND_STAGES` which demand stages it serves. That declaration
is what lets an engagement offer "instantiate something useful here" without a
person browsing the whole library, and it is why the kinds are an enum rather
than a free-text tag: a tag set nobody curates ends up with `proposal`,
`Proposal` and `proposal-v2` in it, and stage coverage stops being computable.
| Kind | What it is for | Stages it serves |
|---|---|---|
| `discovery` | The questions that surface what a customer is actually training, before anyone scopes it | qualification, scoping |
| `qualification` | Weighted dimensions that turn a judgement about a deal into a score somebody can argue with | qualification |
| `poc` | What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever | poc |
| `proposal` | Language blocks assembled into a proposal, so wording that survived procurement is reused | proposal, procurement |
| `pricing` | The inputs behind a quoted price: term, commitment shape, and what the block cost | proposal, procurement |
| `architecture` | A deployment shape that has already worked, described well enough to be copied | scoping, poc, deployment |
| `case_study` | A deployment written up as evidence, for the next customer who asks whether this is real | qualification, proposal, expansion |
| `narrative` | The technical argument for why this capacity suits this workload, written once | qualification, scoping, proposal |
| `playbook` | The end-to-end sequence for a deployment, spanning every stage rather than one | all eight open stages |
Two details in that table are decisions rather than data entry. `playbook` maps
to `DEMAND_OPEN_STAGES` and is the only kind that does; spanning the whole live
motion is exactly what distinguishes a playbook from the eight kinds that answer
one question each. And the **closed** stages appear nowhere, on purpose: a deal
that is won or lost has left the motion, and offering to instantiate a discovery
template into it would be an invitation to file work against a dead deal.
`pricing` deserves a word, because it looks like it belongs in the ledger. It
does not carry a price — supplier cost and break-even live in the commitment and
are gated on `economics:read`. It carries the *reasoning* behind a quote: which
term was offered, what commitment shape it assumed, what the block cost then.
That is practice, and the motion library is not the cost book — the same
classification `read-guards.ts` applies when it gates every motion GET on
`book:read` rather than `economics:read`.
## The loop
```
library template ──instantiate──▶ engagement artefact ──promote──▶ template v2
▲ │
└──────────────────────── supersedes_id ◀──────────────────────────┘
origin_artifact_id
```
This is the only reason the feature exists, and two rules are what make it a
mechanism rather than a slogan.
**A used template is never edited in place.** Instantiating increments
`usage_count`; once it is above zero a `PATCH` returns `409 template_in_use` and
names the fix (`POST .../versions`). A live engagement whose template changed
underneath it has lost its provenance, and provenance is the only thing that
makes "this came from playbook v3" mean anything a quarter later. A template at
`usage_count = 0` is still a draft and edits freely.
**Promotion writes a new row, never an update.** `version = previous + 1`,
`supersedes_id` back to its predecessor, `origin_artifact_id` back to the
artefact that proved it, `visibility: 'shared'`. A draft cannot be promoted —
`409 artifact_not_final` — because the library is what the next deployment
copies. Promoting twice is `409 already_promoted`. The whole of it happens in
the transaction the `mutation()` chokepoint already opens, and it emits an
activity onto the account of the deal that proved it, so the loop is visible on
a customer timeline rather than only in a library.
**Every collision on `(slug, version)` is a 409, never a 500.** The unique
constraint is real and callers hit it in ordinary use: "Proposal Blocks" is a
shipped starter, so the obvious first template anyone authors derives a slug
that already exists. Creating one is checked first and answers
`409 template_slug_exists` naming the versions endpoint; allocating a version
takes `FOR UPDATE` on the newest row of the lineage so two concurrent forks
queue rather than race; and because a brand-new slug has no row to lock, the
insert also catches Postgres' `23505` and answers `409
template_version_conflict`. Verified by running four concurrent creates of one
title: one 200 and three 409s.
Those two constraints form an FK cycle —
`motion_templates.origin_artifact_id ↔ engagement_artifacts.template_id` — which
is why migration `0014_motion.sql` is hand-written and adds one of the two
constraints in a separate `ALTER TABLE` after both tables exist. Drizzle will
not order that for you.
## Private and shared — a departure, stated out loud
`permissions.ts` says plainly that every read endpoint returns the whole book,
because **no row-level filter exists anywhere in the query layer**. Motion is
the first exception, and it is written down here so it cannot be discovered by
surprise:
- `visibility = 'shared'` — book-wide on `book:read`, like everything else.
- `visibility = 'private'` — readable and writable only by `owner_user_id`, plus
platform admins. A real `WHERE` clause, in `visibleTemplates()`.
It was worth the departure because a draft proposal for a live deal is not the
same object as a contract, and a library nobody can draft in privately becomes a
library nobody drafts in.
Three details are load-bearing:
- The predicate is `owner_user_id = $viewer`, which is NULL-safe by
construction, so an orphaned private row would be invisible to everyone but a
platform admin. Writing it as `owner = $me OR owner IS NULL` would hand every
departed user's drafts to the whole workspace. As it happens Postgres will not
currently produce such a row — found by running the delete, not by reading the
DDL: `motion_templates_private_has_owner_check` is evaluated on the UPDATE
that `ON DELETE SET NULL` performs, so **deleting a user who owns a private
template fails outright**. Whoever adds a member-removal path has to archive or
reassign those rows first. The NULL-safe predicate stays regardless, so that
relaxing the CHECK cannot quietly publish them.
- **A lineage the reader cannot see never supplies content to one they can.**
The version number for a new version is allocated against the whole lineage,
because `(slug, version)` is unique and a private fork still consumes a
number — but that query returns a number and nothing else, `FOR UPDATE`.
`supersedes_id` and the fallback summary come from a second, visibility-
filtered read. Answering both from one unfiltered query is how a private
draft's summary reached the shared library, and it is pinned by a test named
for the decision.
- **A private template somebody else owns answers exactly as an unknown id
does** — `404`, not `403`. A 403 on a specific id is a working existence
oracle: it tells you a colleague has a draft at that address, which is half of
what the filter exists to withhold.
- A platform admin gets **no predicate at all**. That is deliberate, and it is
pinned by a test that says so in its name, because it looks like the filter
failing open.
- Piggy filters to `visibility = 'shared'` unconditionally, under every filter a
model can send. A model that can be talked into reading someone's private
draft is a leak with extra steps.
Ownership and capability are different questions and fail differently on
purpose: a missing `motion:write` is `insufficient_permission` from the chokepoint
before the body is read, and a wrong owner is `not_owner` once the row is known.
A shared template is published, not communal — it is still only its owner's to
edit. Sharing one needs `motion:publish`, which is a lead's judgement, because
publishing is what everybody else copies next quarter.
## Scores move, and the movement is the evidence
`qualification_scores` is append-only — never updated, never deleted. A mutable
current score is a number somebody can make true afterwards; the trajectory
across an engagement is the evidence that qualification happened at all.
Scores are integers, in **basis points of the maximum** (010000), computed with
integer arithmetic and rounded half-up — the same rule as money, for the same
reason. Weights need not sum to 100. Band boundaries are inclusive at `min`
(3500, 5500, 7500), pinned exactly, because an off-by-one there silently changes
what a seller is told to do. The API computes the score from the dimensions and
refuses a posted `basisPoints` outright: a score somebody can send is a score
somebody can fix.
`qualification_scores.dimensions` stores the weights rather than referencing the
framework, so history does not silently re-weight itself the day someone
publishes a new version of the scorecard.
## The starter library
Twelve authored templates ship in `packages/db/src/seed/motion/`, covering all
nine `MOTION_KINDS`. They are **not** demo data and carry no `DEMO — ` prefix —
they are product content, like `docs/learn-scripts.md`. Seeded `isSystem: true`,
`visibility: 'shared'`, `version: 1`, unowned.
Idempotency rests on `onConflictDoNothing({ target: [slug, version] })` against
`motion_templates_slug_version_key`, which is a **silent no-op** without that
constraint. CI counts `motion_templates` across two seed runs for exactly that
reason; counting `contacts` alone could never have caught it, because `contacts`
is idempotent by an explicit existence check.
Two demo engagements live in `packages/db/src/seed/demo/motion.ts`: one mid-POC
with three qualification scores, because a single score is a number and three
are a trajectory, and one closed-won with a promoted case study, which puts a
version 2 into the library and populates both halves of the FK cycle on a first
run.
## Deliberately not built
- **Mermaid.** Reference-architecture diagrams arrive as `mermaid` fences and
render as a labelled, scrollable code block with a copy button — the same
`CodeBlock` whether the diagram came from a template's `fields` or from a
fence inside a promoted body. A 2 MB diagram renderer and a CSP hash change
were not worth it for this change; the block already reads the language off
the fence, so the renderer is the only missing piece if it is ever wanted.
- **A single markdown renderer.** The app now carries two: `streamdown`, which
Piggy's dock uses for a half-finished token stream in 22rem, and plain
`react-markdown` for page-width authored prose, in
`components/motion/Markdown.tsx`. They are different problems and the second
costs ~20 kB in a lazy chunk that only the Motion routes load — the eager
entry chunk is unchanged. Consolidating them is a reasonable future change and
a bad one to make blind.
- **Export to PDF.** A proposal leaves PIG by being copied into whatever the
customer already reads. Rendering paper is a different product.
- **Per-customer sharing links.** There is no way to hand a customer a URL onto
a template or an artefact, and no third visibility between `private` and
`shared`. Those two earned their complexity; a third needs an ACL table, and
the moment the library has an ACL nobody drafts in it. An external link is a
larger decision again — an unauthenticated route serving authored practice —
and it belongs to whoever builds a customer-facing surface, not to this
change.
- **Full-text search.** `?q=` is `ILIKE` over title and summary, with `%` and
`_` escaped so a wildcard a caller types is a literal. Body text is not
searched. Postgres `tsvector` is the right answer when the library is large
enough for that to be the complaint; twelve starter templates are not.
- **Pagination.** As everywhere else here: a hard `.limit()` instead.
- **Contiguous version numbers.** A private fork consumes a number in the shared
lineage, so a reader who cannot see the fork sees a hole where it sits. The
alternative — a separate counter per visibility — makes "which of these is
newer" unanswerable across a fork that is later published, which is the
question the lineage exists to answer. The hole is the honest rendering.
+1
View File
@@ -2,6 +2,7 @@ export * from './ontology';
export * from './calendar';
export * from './learn';
export * from './margin';
export * from './motion';
export * from './permissions';
export * from './piggy-context';
export * from './theme';
+226
View File
@@ -0,0 +1,226 @@
/**
* Motion the reusable practice that sits alongside the ledger.
*
* PIG already models what capacity was bought and what was sold. It does not
* model the *motion*: the repeatable work that turns a messy customer
* conversation into a scoped deployment, and turns that deployment into an
* asset the next one reuses. `DEMAND_STAGES` is already exactly that sequence,
* so Motion is deliberately NOT a parallel entity tree every kind below
* declares which demand stages it serves, and the artifacts hang off deals that
* already exist.
*
* The loop the whole feature exists for:
*
* library template --instantiate--> engagement artifact --promote--> template v2
*
* A template is never edited in place once it has been used; promotion writes a
* new version row pointing back at its predecessor and at the artifact that
* proved it. That is what makes "each deployment makes the next one easier" a
* mechanism rather than a slogan, and it is why `usageCount` and the lineage
* columns are load-bearing rather than decoration.
*
* No zod in this file. `packages/core` is the ontology layer; the API route
* modules build their schemas from these constants, so removing a value stops
* validating rather than silently persisting.
*/
import { DEMAND_OPEN_STAGES, type DemandStage } from './ontology';
// ---------------------------------------------------------------------------
// The nine kinds
// ---------------------------------------------------------------------------
export const MOTION_KINDS = [
'discovery',
'qualification',
'poc',
'proposal',
'pricing',
'architecture',
'case_study',
'narrative',
'playbook',
] as const;
export type MotionKind = (typeof MOTION_KINDS)[number];
export const MOTION_KIND_LABELS: Record<MotionKind, string> = {
discovery: 'Discovery',
qualification: 'Qualification framework',
poc: 'POC structure',
proposal: 'Proposal blocks',
pricing: 'Pricing and packaging',
architecture: 'Reference architecture',
case_study: 'Case study',
narrative: 'Technical narrative',
playbook: 'Deployment playbook',
};
/**
* One line each, in the register of `ontology.ts`: what the kind is *for*, not
* what it contains. These strings are shown in the library filter and in
* Piggy's tool description, so they are the only definition most people read.
*/
export const MOTION_KIND_DESCRIPTIONS: Record<MotionKind, string> = {
discovery: 'The questions that surface what a customer is actually training, before anyone scopes it.',
qualification: 'Weighted dimensions that turn a judgement about a deal into a score somebody can argue with.',
poc: 'What a proof of concept must demonstrate, and what closes it — so a pilot cannot run forever.',
proposal: 'Language blocks assembled into a proposal, so wording that survived procurement is reused.',
pricing: 'The inputs behind a quoted price: term, commitment shape, and what the block cost.',
architecture: 'A deployment shape that has already worked, described well enough to be copied.',
case_study: 'A deployment written up as evidence, for the next customer who asks whether this is real.',
narrative: 'The technical argument for why this capacity suits this workload, written once.',
playbook: 'The end-to-end sequence for a deployment, spanning every stage rather than one.',
};
/**
* Which demand stages each kind serves.
*
* The closed stages are absent everywhere on purpose: a deal that is won or
* lost has left the motion, and offering to instantiate a discovery template
* into it would be an invitation to file work against a dead deal. `playbook`
* spans the whole live motion, which is what distinguishes it from the eight
* kinds that answer one stage.
*/
export const MOTION_KIND_STAGES: Record<MotionKind, readonly DemandStage[]> = {
discovery: ['qualification', 'scoping'],
qualification: ['qualification'],
poc: ['poc'],
proposal: ['proposal', 'procurement'],
pricing: ['proposal', 'procurement'],
architecture: ['scoping', 'poc', 'deployment'],
case_study: ['qualification', 'proposal', 'expansion'],
narrative: ['qualification', 'scoping', 'proposal'],
playbook: DEMAND_OPEN_STAGES,
};
/**
* Private is the default, and it is a real access rule rather than a label
* see the header of `packages/db/src/schema/motion.ts`. A library nobody can
* draft in privately becomes a library nobody drafts in.
*/
export const MOTION_VISIBILITIES = ['private', 'shared'] as const;
export type MotionVisibility = (typeof MOTION_VISIBILITIES)[number];
export const MOTION_VISIBILITY_LABELS: Record<MotionVisibility, string> = {
private: 'Private',
shared: 'Shared',
};
/** Only a `final` artifact may be promoted — see the promotion path in the API. */
export const ARTIFACT_STATUSES = ['draft', 'review', 'final'] as const;
export type ArtifactStatus = (typeof ARTIFACT_STATUSES)[number];
export const ARTIFACT_STATUS_LABELS: Record<ArtifactStatus, string> = {
draft: 'Draft',
review: 'In review',
final: 'Final',
};
export const ENGAGEMENT_STATUSES = ['open', 'won', 'lost', 'paused'] as const;
export type EngagementStatus = (typeof ENGAGEMENT_STATUSES)[number];
export const ENGAGEMENT_STATUS_LABELS: Record<EngagementStatus, string> = {
open: 'Open',
won: 'Won',
lost: 'Lost',
paused: 'Paused',
};
export function isMotionKind(value: string): value is MotionKind {
return (MOTION_KINDS as readonly string[]).includes(value);
}
export function isMotionVisibility(value: string): value is MotionVisibility {
return (MOTION_VISIBILITIES as readonly string[]).includes(value);
}
export function isArtifactStatus(value: string): value is ArtifactStatus {
return (ARTIFACT_STATUSES as readonly string[]).includes(value);
}
export function isEngagementStatus(value: string): value is EngagementStatus {
return (ENGAGEMENT_STATUSES as readonly string[]).includes(value);
}
// ---------------------------------------------------------------------------
// Scoring
// ---------------------------------------------------------------------------
/** Each dimension is scored on this scale, inclusive. */
export const MOTION_MIN_DIMENSION_SCORE = 0;
export const MOTION_MAX_DIMENSION_SCORE = 4;
/** The full scale of a motion score. Basis points, exactly as money is cents. */
export const MOTION_BASIS_POINTS_MAX = 10_000;
export interface MotionDimensionScore {
readonly id: string;
readonly weight: number;
readonly score: number;
}
/**
* Weighted score in basis points of the maximum (010000). Weights need not
* sum to 100.
*
* Basis points rather than a float for the same reason money is cents: a score
* decides what a seller is told to do, it is persisted, and 0.55 is not 0.55 in
* binary. The whole computation stays in integers and rounds half-up at the
* single division, so the stored number is the number that was displayed.
*
* Inputs are normalised rather than rejected a dimension carrying a
* non-finite weight is a bug upstream, and throwing here would take a
* dashboard down rather than under-weight one row.
*/
export function motionScoreBasisPoints(dimensions: readonly MotionDimensionScore[]): number {
let weightTotal = 0;
let weightedScoreTotal = 0;
for (const dimension of dimensions) {
if (!Number.isFinite(dimension.weight) || !Number.isFinite(dimension.score)) continue;
const weight = Math.round(dimension.weight);
if (weight <= 0) continue;
const score = Math.min(
MOTION_MAX_DIMENSION_SCORE,
Math.max(MOTION_MIN_DIMENSION_SCORE, Math.round(dimension.score)),
);
weightTotal += weight;
weightedScoreTotal += weight * score;
}
// No weight is not a zero score dressed up — it is "nothing was asked". Both
// answer 0 here because a qualification with no dimensions has to render as
// something, and NaN renders as `NaN`.
if (weightTotal === 0) return 0;
const numerator = MOTION_BASIS_POINTS_MAX * weightedScoreTotal;
const denominator = MOTION_MAX_DIMENSION_SCORE * weightTotal;
// Half-up, in integers. `Math.round` on the quotient would be a float
// division first, which is exactly the step that loses the half.
return Math.floor((2 * numerator + denominator) / (2 * denominator));
}
export const MOTION_BANDS = [
{ min: 0, max: 3499, label: 'Decline or defer', tone: 'danger' },
{ min: 3500, max: 5499, label: 'Not yet', tone: 'warning' },
{ min: 5500, max: 7499, label: 'Qualified', tone: 'info' },
{ min: 7500, max: 10_000, label: 'Strategic', tone: 'positive' },
] as const;
export type MotionBand = (typeof MOTION_BANDS)[number];
export type MotionBandTone = MotionBand['tone'];
/**
* The band a score falls in. Boundaries are inclusive at `min`, so 3500 is
* "Not yet" and 3499 is not an off-by-one here changes what a seller is told
* to do without changing any number they can see.
*
* Out-of-range input clamps to an end band rather than returning undefined,
* because every caller renders this and none of them has a null branch.
*/
export function motionBand(basisPoints: number): MotionBand {
const clamped = Math.min(
MOTION_BASIS_POINTS_MAX,
Math.max(0, Number.isFinite(basisPoints) ? Math.round(basisPoints) : 0),
);
return MOTION_BANDS.find((band) => clamped >= band.min && clamped <= band.max) ?? MOTION_BANDS[0];
}
+12
View File
@@ -12,6 +12,8 @@ export const CAPABILITIES = [
'data:import',
'fact:review',
'integration:connect',
'motion:write',
'motion:publish',
'settings:admin',
'book:read',
'economics:read',
@@ -36,6 +38,8 @@ export const TEAM_CAPABILITIES = [
'data:import',
'fact:review',
'integration:connect',
'motion:write',
'motion:publish',
] as const satisfies readonly Capability[];
export type TeamCapability = (typeof TEAM_CAPABILITIES)[number];
@@ -93,6 +97,14 @@ export const TEAM_CAPABILITY_RULES: Readonly<Record<TeamCapability, CapabilityRu
// research team's judgement about evidence, not a commercial authority.
'fact:review': { teams: ['research'], minimumRole: 'admin' },
'integration:connect': { teams: TEAMS, minimumRole: 'admin' },
// Every team runs a motion, which is why this is not scoped to the
// commercial pair: research authors reference architectures and technical
// narratives as much as demand authors proposals.
'motion:write': { teams: TEAMS, minimumRole: 'member' },
// Publishing is what everybody else copies next quarter — a lead's
// judgement, not a member's. Authoring privately stays open to everyone
// precisely so this gate can be a real one.
'motion:publish': { teams: TEAMS, minimumRole: 'lead' },
};
/**
+7
View File
@@ -52,6 +52,13 @@ export const PIGGY_PAGE_ROUTES = [
'/team',
'/facts',
'/learn',
// Order is load-bearing for the first time in this list. `toPiggyPageRoute`
// takes the FIRST match and matches on prefix, so '/motion' listed above its
// children would swallow '/motion/library/:id' and report the whole feature
// as one page. Most specific first.
'/motion/library',
'/motion/engagements',
'/motion',
'/settings',
'/piggy',
] as const;
+187
View File
@@ -0,0 +1,187 @@
/**
* Tests for the motion scoring arithmetic.
*
* A qualification score is not a dashboard ornament: the band it lands in is
* what a seller is told to do about a deal, and the row is append-only
* evidence that the judgement was made. So the cases below pin the
* *decisions* the empty book, the rounding direction, and above all the band
* boundaries, each of which a plausible-but-wrong implementation gets wrong by
* exactly one basis point and reports no error about.
*/
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import { DEMAND_STAGES } from '../src/ontology';
import {
MOTION_BANDS,
MOTION_KINDS,
MOTION_KIND_DESCRIPTIONS,
MOTION_KIND_LABELS,
MOTION_KIND_STAGES,
isArtifactStatus,
isMotionKind,
isMotionVisibility,
motionBand,
motionScoreBasisPoints,
} from '../src/motion';
describe('motionScoreBasisPoints', () => {
it('returns 0 for an empty framework rather than NaN', () => {
// A framework with no dimensions renders somewhere. `NaN` renders as "NaN".
const score = motionScoreBasisPoints([]);
assert.equal(score, 0);
assert.ok(!Number.isNaN(score));
});
it('returns 0 rather than throwing when every weight is zero', () => {
// The naive version divides by the weight total and produces Infinity,
// which `motionBand` would then clamp to "Strategic" — a deal nobody
// scored recommended as the best one in the book.
const score = motionScoreBasisPoints([
{ id: 'fit', weight: 0, score: 4 },
{ id: 'budget', weight: 0, score: 4 },
]);
assert.equal(score, 0);
});
it('scores a full house at exactly the maximum', () => {
assert.equal(
motionScoreBasisPoints([
{ id: 'fit', weight: 30, score: 4 },
{ id: 'budget', weight: 70, score: 4 },
]),
10_000,
);
});
it('does not require weights to sum to 100', () => {
// Two frameworks expressing the same judgement must score the same, or the
// number means nothing across templates.
const outOf100 = motionScoreBasisPoints([
{ id: 'fit', weight: 50, score: 4 },
{ id: 'budget', weight: 50, score: 2 },
]);
const outOf6 = motionScoreBasisPoints([
{ id: 'fit', weight: 3, score: 4 },
{ id: 'budget', weight: 3, score: 2 },
]);
assert.equal(outOf100, 7500);
assert.equal(outOf6, 7500);
});
it('rounds half-up rather than truncating', () => {
// 10000 × 5 / (4 × 8) = 1562.5 exactly. Truncation gives 1562, which is
// the whole difference between this and a naive `Math.floor` — small, and
// it moves a score across a band boundary once every few hundred deals.
const score = motionScoreBasisPoints([
{ id: 'fit', weight: 5, score: 1 },
{ id: 'budget', weight: 3, score: 0 },
]);
assert.equal(score, 1563);
});
it('does not let a zero-weight dimension move the total', () => {
// The case that flatters: a dimension somebody scored 4 and weighted out
// of the framework must not drag the score up on its way past.
const withoutIt = motionScoreBasisPoints([{ id: 'fit', weight: 10, score: 2 }]);
const withIt = motionScoreBasisPoints([
{ id: 'fit', weight: 10, score: 2 },
{ id: 'vanity', weight: 0, score: 4 },
]);
assert.equal(withoutIt, 5000);
assert.equal(withIt, withoutIt);
});
it('always returns an integer inside 010000', () => {
for (const weight of [1, 3, 7, 17, 100]) {
for (const score of [0, 1, 2, 3, 4]) {
const basisPoints = motionScoreBasisPoints([
{ id: 'a', weight, score },
{ id: 'b', weight: weight + 1, score: 4 - score },
]);
assert.ok(Number.isInteger(basisPoints), `${weight}/${score} produced a fraction`);
assert.ok(basisPoints >= 0 && basisPoints <= 10_000, `${basisPoints} is out of range`);
}
}
});
});
describe('motionBand', () => {
/**
* The boundaries, one assertion per edge. Inclusive at `min`: an
* implementation using `>` instead of `>=` passes every other test in this
* file and tells a seller to defer a deal that qualified.
*/
it('is inclusive at the lower edge of every band', () => {
assert.equal(motionBand(3500).label, 'Not yet');
assert.equal(motionBand(5500).label, 'Qualified');
assert.equal(motionBand(7500).label, 'Strategic');
});
it('keeps the basis point below each edge in the band underneath', () => {
assert.equal(motionBand(3499).label, 'Decline or defer');
assert.equal(motionBand(5499).label, 'Not yet');
assert.equal(motionBand(7499).label, 'Qualified');
});
it('covers both ends of the scale', () => {
assert.equal(motionBand(0).label, 'Decline or defer');
assert.equal(motionBand(10_000).label, 'Strategic');
});
it('leaves no gap and no overlap across the whole range', () => {
// Written as a sweep because a hand-edited band table is exactly the kind
// of data where a typo produces a score that belongs to two bands, or none.
for (let basisPoints = 0; basisPoints <= 10_000; basisPoints += 1) {
const matches = MOTION_BANDS.filter(
(band) => basisPoints >= band.min && basisPoints <= band.max,
);
assert.equal(matches.length, 1, `${basisPoints} matched ${matches.length} bands`);
}
});
});
describe('the kind register', () => {
it('labels and describes every kind', () => {
for (const kind of MOTION_KINDS) {
assert.ok(MOTION_KIND_LABELS[kind], `${kind} has no label`);
assert.ok(MOTION_KIND_DESCRIPTIONS[kind], `${kind} has no description`);
}
});
it('maps every kind onto stages that actually exist', () => {
// The stage list is the demand pipeline, not a second vocabulary. A typo
// here would produce a template nothing can ever be filed against.
for (const kind of MOTION_KINDS) {
const stages = MOTION_KIND_STAGES[kind];
assert.ok(stages.length > 0, `${kind} serves no stage`);
for (const stage of stages) {
assert.ok(DEMAND_STAGES.includes(stage), `${kind} claims unknown stage ${stage}`);
}
}
});
it('never files a kind against a closed stage', () => {
// A won or lost deal has left the motion; offering to instantiate into it
// is an invitation to do work against a dead deal.
for (const kind of MOTION_KINDS) {
for (const stage of MOTION_KIND_STAGES[kind]) {
assert.ok(stage !== 'closed_won' && stage !== 'closed_lost', `${kind} serves ${stage}`);
}
}
});
it('gives the playbook the whole live motion', () => {
assert.equal(MOTION_KIND_STAGES.playbook.length, 8);
});
});
describe('type guards', () => {
it('accepts members and rejects near misses', () => {
assert.equal(isMotionKind('playbook'), true);
assert.equal(isMotionKind('Playbook'), false);
assert.equal(isMotionVisibility('shared'), true);
assert.equal(isMotionVisibility('public'), false, 'shared is book-wide, not public');
assert.equal(isArtifactStatus('final'), true);
assert.equal(isArtifactStatus('published'), false);
});
});
+14 -1
View File
@@ -134,7 +134,14 @@ describe('reads', () => {
describe('the whole role × capability matrix', () => {
const EXPECTED: Readonly<Record<TeamRole, readonly Capability[]>> = {
viewer: ['book:read', 'team:read'],
member: ['book:read', 'economics:read', 'team:read', 'deal:write', 'activity:write'],
member: [
'book:read',
'economics:read',
'team:read',
'deal:write',
'activity:write',
'motion:write',
],
lead: [
'book:read',
'economics:read',
@@ -142,6 +149,8 @@ describe('the whole role × capability matrix', () => {
'deal:write',
'activity:write',
'commitment:write',
'motion:write',
'motion:publish',
],
admin: [
'book:read',
@@ -153,6 +162,8 @@ describe('the whole role × capability matrix', () => {
'contract:sign',
'data:import',
'integration:connect',
'motion:write',
'motion:publish',
],
};
@@ -188,6 +199,8 @@ describe('the whole role × capability matrix', () => {
'data:import',
'fact:review',
'integration:connect',
'motion:write',
'motion:publish',
]),
);
});
+106
View File
@@ -0,0 +1,106 @@
-- Motion: the library, engagements, artifacts and the append-only score log.
--
-- Hand-written, and it has to be. `motion_templates.origin_artifact_id`
-- references `engagement_artifacts`, and `engagement_artifacts.template_id`
-- references `motion_templates` — the promotion loop is a foreign key cycle,
-- and there is no ordering of two CREATE TABLE statements that satisfies both.
-- Drizzle emits every constraint with the table it belongs to and will not
-- order this for you, so the second half of the cycle is added below as its own
-- ALTER TABLE once both tables exist. Generated output for this schema fails on
-- `relation "engagement_artifacts" does not exist`.
CREATE TABLE "motion_templates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"kind" text NOT NULL,
"slug" text NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"title" text NOT NULL,
"summary" text NOT NULL,
"body" text NOT NULL,
"fields" jsonb,
"stage" text NOT NULL,
"visibility" text DEFAULT 'private' NOT NULL,
"owner_user_id" uuid,
"supersedes_id" uuid,
"origin_artifact_id" uuid,
"is_system" boolean DEFAULT false NOT NULL,
"usage_count" integer DEFAULT 0 NOT NULL,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "motion_templates_slug_version_key" UNIQUE("slug","version"),
CONSTRAINT "motion_templates_kind_check" CHECK ("motion_templates"."kind" IN ('discovery', 'qualification', 'poc', 'proposal', 'pricing', 'architecture', 'case_study', 'narrative', 'playbook')),
CONSTRAINT "motion_templates_stage_check" CHECK ("motion_templates"."stage" IN ('qualification', 'legal', 'scoping', 'proposal', 'procurement', 'poc', 'deployment', 'expansion', 'closed_won', 'closed_lost')),
CONSTRAINT "motion_templates_visibility_check" CHECK ("motion_templates"."visibility" IN ('private', 'shared')),
CONSTRAINT "motion_templates_version_positive_check" CHECK ("motion_templates"."version" > 0),
CONSTRAINT "motion_templates_private_has_owner_check" CHECK ("motion_templates"."visibility" <> 'private' OR "motion_templates"."owner_user_id" IS NOT NULL)
);
--> statement-breakpoint
CREATE TABLE "engagements" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"demand_deal_id" uuid NOT NULL,
"playbook_template_id" uuid,
"owner_user_id" uuid,
"status" text DEFAULT 'open' NOT NULL,
"summary" text,
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
"closed_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "engagements_demand_deal_key" UNIQUE("demand_deal_id"),
CONSTRAINT "engagements_status_check" CHECK ("engagements"."status" IN ('open', 'won', 'lost', 'paused'))
);
--> statement-breakpoint
CREATE TABLE "engagement_artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"engagement_id" uuid NOT NULL,
"template_id" uuid,
"kind" text NOT NULL,
"stage" text NOT NULL,
"title" text NOT NULL,
"body" text NOT NULL,
"fields" jsonb,
"status" text DEFAULT 'draft' NOT NULL,
"authored_by_user_id" uuid,
"promoted_template_id" uuid,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "engagement_artifacts_kind_check" CHECK ("engagement_artifacts"."kind" IN ('discovery', 'qualification', 'poc', 'proposal', 'pricing', 'architecture', 'case_study', 'narrative', 'playbook')),
CONSTRAINT "engagement_artifacts_stage_check" CHECK ("engagement_artifacts"."stage" IN ('qualification', 'legal', 'scoping', 'proposal', 'procurement', 'poc', 'deployment', 'expansion', 'closed_won', 'closed_lost')),
CONSTRAINT "engagement_artifacts_status_check" CHECK ("engagement_artifacts"."status" IN ('draft', 'review', 'final'))
);
--> statement-breakpoint
CREATE TABLE "qualification_scores" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"engagement_id" uuid NOT NULL,
"framework_template_id" uuid,
"dimensions" jsonb NOT NULL,
"basis_points" integer NOT NULL,
"band" text NOT NULL,
"note" text,
"scored_by_user_id" uuid,
"scored_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "qualification_scores_basis_points_check" CHECK ("qualification_scores"."basis_points" >= 0 AND "qualification_scores"."basis_points" <= 10000)
);
--> statement-breakpoint
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_supersedes_id_motion_templates_id_fk" FOREIGN KEY ("supersedes_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_demand_deal_id_demand_deals_id_fk" FOREIGN KEY ("demand_deal_id") REFERENCES "public"."demand_deals"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_playbook_template_id_motion_templates_id_fk" FOREIGN KEY ("playbook_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagements" ADD CONSTRAINT "engagements_owner_user_id_users_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_template_id_motion_templates_id_fk" FOREIGN KEY ("template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_authored_by_user_id_users_id_fk" FOREIGN KEY ("authored_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "engagement_artifacts" ADD CONSTRAINT "engagement_artifacts_promoted_template_fk" FOREIGN KEY ("promoted_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-- The other half of the loop, and the reason this file is hand-written.
ALTER TABLE "motion_templates" ADD CONSTRAINT "motion_templates_origin_artifact_fk" FOREIGN KEY ("origin_artifact_id") REFERENCES "public"."engagement_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_engagement_id_engagements_id_fk" FOREIGN KEY ("engagement_id") REFERENCES "public"."engagements"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "qualification_scores" ADD CONSTRAINT "qualification_scores_framework_template_fk" FOREIGN KEY ("framework_template_id") REFERENCES "public"."motion_templates"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "motion_templates_kind_stage_idx" ON "motion_templates" USING btree ("kind","stage");--> statement-breakpoint
CREATE INDEX "motion_templates_visibility_kind_idx" ON "motion_templates" USING btree ("visibility","kind");--> statement-breakpoint
CREATE INDEX "motion_templates_owner_idx" ON "motion_templates" USING btree ("owner_user_id");--> statement-breakpoint
CREATE INDEX "engagements_status_idx" ON "engagements" USING btree ("status");--> statement-breakpoint
CREATE INDEX "engagement_artifacts_engagement_stage_idx" ON "engagement_artifacts" USING btree ("engagement_id","stage");--> statement-breakpoint
CREATE INDEX "qualification_scores_engagement_idx" ON "qualification_scores" USING btree ("engagement_id","scored_at" DESC);
@@ -99,6 +99,13 @@
"when": 1786700000000,
"tag": "0013_learn_self_hosted_provider",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1786800000000,
"tag": "0014_motion",
"breakpoints": true
}
]
}
+2
View File
@@ -12,6 +12,7 @@
* contracts MSA, DPA, SLA, order forms, obligations
* compliance export control as a predicate on the match
* calendar the one dated row type nothing else owns
* motion the reusable practice bound to the demand stages
* agent the leased task queue and evidence-bearing facts
* fields user-defined fields
*/
@@ -26,6 +27,7 @@ export * from './contracts';
export * from './compliance';
export * from './calendar';
export * from './learn';
export * from './motion';
export * from './agent';
export * from './fields';
export * from './integrations';
+294
View File
@@ -0,0 +1,294 @@
/**
* Motion the library, the engagements, and the loop between them.
*
* Four tables, and three of the decisions in them are load-bearing.
*
* **A used template is never edited in place.** `usage_count` is incremented
* when a template is instantiated into an engagement, and the API refuses a
* `PATCH` once it is above zero a new version row is written instead,
* carrying `supersedes_id` back to its predecessor and `origin_artifact_id`
* forward to the artifact that proved it. A live engagement whose template
* changed underneath it has lost its provenance, and provenance is the only
* thing that makes "this came from playbook v3" mean anything a quarter later.
* That is also why `(slug, version)` is unique: the seed writes
* `onConflictDoNothing({ target: [slug, version] })`, which is a silent no-op
* without a constraint to conflict on, and that has already duplicated seed
* data twice in this codebase.
*
* **`visibility = 'private'` is a real access rule, and it is this codebase's
* first row-level filter.** `permissions.ts` says plainly that every read
* endpoint returns the whole book, because no row-level team filter exists
* anywhere in the query layer. Motion is a deliberate exception, stated here
* so it cannot be discovered by surprise: a draft proposal for a live deal is
* not the same object as a contract, and a library nobody can draft in
* privately becomes a library nobody drafts in. `shared` is book-wide on
* `book:read` exactly like everything else; `private` is readable and writable
* by `owner_user_id` and by a platform admin, and by nobody else.
*
* The CHECK below refuses a private row with no owner, because such a row is
* readable by nobody and writable by nobody a leak the day someone "fixes"
* the query that appears to be dropping rows. It also, measured by running the
* delete rather than by reading the DDL, makes `ON DELETE SET NULL` on
* `owner_user_id` unreachable for a private row: Postgres evaluates the CHECK
* on the UPDATE the referential action performs, so deleting a user who owns
* one raises `motion_templates_private_has_owner_check` and the delete fails.
* Private authorship therefore blocks a user delete today, and whoever adds a
* member-removal path has to archive or reassign those rows first. The read
* query still treats a private row with a null owner as invisible to everyone
* but a platform admin, so that relaxing the CHECK cannot quietly publish
* them do not write that filter as `owner_user_id = $me OR owner_user_id IS
* NULL`.
*
* **`qualification_scores` is append-only.** Never updated, never deleted. The
* movement of a score across an engagement is the evidence that qualification
* happened at all; a mutable current score is a number somebody can make true
* afterwards. Everything else archives rather than deletes, as elsewhere in
* PIG.
*
* The FK cycle between `motion_templates.origin_artifact_id` and
* `engagement_artifacts.template_id` is intentional it is the loop and it
* is why migration `0014_motion.sql` is hand-written, adding one of the two
* constraints in a separate `ALTER TABLE` after both tables exist.
*/
import { sql } from 'drizzle-orm';
import {
boolean,
check,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
type AnyPgColumn,
} from 'drizzle-orm/pg-core';
import {
ARTIFACT_STATUSES,
DEMAND_STAGES,
ENGAGEMENT_STATUSES,
MOTION_BASIS_POINTS_MAX,
MOTION_KINDS,
MOTION_VISIBILITIES,
} from '@pig/core';
import { demandDeals } from './demand';
import { users } from './identity';
/**
* Render a value set as a SQL `IN` list from the ontology constant.
*
* Copied from `learn.ts` rather than imported: it is a private detail of how a
* schema file renders its constraints, and exporting it would make two files
* that must be able to diverge share one. The values are compile-time literal
* constants from `@pig/core`, never input.
*/
const inList = (values: readonly string[]) =>
sql.raw(values.map((value) => `'${value}'`).join(', '));
export const motionTemplates = pgTable(
'motion_templates',
{
id: uuid('id').primaryKey().defaultRandom(),
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
/** Stable across versions: the slug, not the id, is the identity of a lineage. */
slug: text('slug').notNull(),
version: integer('version').notNull().default(1),
title: text('title').notNull(),
summary: text('summary').notNull(),
/** Markdown. Rendered by the web app; never framed, never executed. */
body: text('body').notNull(),
/** The structured half — scoring dimensions, pricing inputs, checklist items. */
fields: jsonb('fields').$type<Record<string, unknown>>(),
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
visibility: text('visibility', { enum: MOTION_VISIBILITIES }).notNull().default('private'),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
/** The previous version in this lineage. Null on version 1. */
supersedesId: uuid('supersedes_id').references((): AnyPgColumn => motionTemplates.id, {
onDelete: 'set null',
}),
/**
* The engagement artifact this version was promoted from. Half of the
* cycle the migration has to break see the file header.
*/
originArtifactId: uuid('origin_artifact_id').references(
(): AnyPgColumn => engagementArtifacts.id,
{ onDelete: 'set null' },
),
/** The starter library shipped with the product, not somebody's draft. */
isSystem: boolean('is_system').notNull().default(false),
/** Incremented on instantiate. Above zero, this row stops being editable. */
usageCount: integer('usage_count').notNull().default(0),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('motion_templates_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
check('motion_templates_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
check(
'motion_templates_visibility_check',
sql`${t.visibility} IN (${inList(MOTION_VISIBILITIES)})`,
),
check('motion_templates_version_positive_check', sql`${t.version} > 0`),
/**
* Written as an implication so it reads as the rule it encodes: private
* implies owned. An unowned private row is invisible to every query that
* is written correctly, which is precisely why it must not exist.
*/
check(
'motion_templates_private_has_owner_check',
sql`${t.visibility} <> 'private' OR ${t.ownerUserId} IS NOT NULL`,
),
/** Load-bearing for the seed's `onConflictDoNothing` — see the file header. */
unique('motion_templates_slug_version_key').on(t.slug, t.version),
/** The library browse: a kind, at a stage. */
index('motion_templates_kind_stage_idx').on(t.kind, t.stage),
/** The shared-library read, which filters on visibility before anything else. */
index('motion_templates_visibility_kind_idx').on(t.visibility, t.kind),
/** "My drafts", the other half of the private/shared split. */
index('motion_templates_owner_idx').on(t.ownerUserId),
],
);
export const engagements = pgTable(
'engagements',
{
id: uuid('id').primaryKey().defaultRandom(),
/**
* The spine. An engagement has no independent existence it is the motion
* being run against a deal that already exists, so it dies with the deal.
*/
demandDealId: uuid('demand_deal_id')
.notNull()
.references(() => demandDeals.id, { onDelete: 'cascade' }),
playbookTemplateId: uuid('playbook_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }),
status: text('status', { enum: ENGAGEMENT_STATUSES }).notNull().default('open'),
summary: text('summary'),
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
closedAt: timestamp('closed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('engagements_status_check', sql`${t.status} IN (${inList(ENGAGEMENT_STATUSES)})`),
/**
* One engagement per deal. Two engagements on one deal would give the same
* opportunity two qualification histories and two answers to "what stage
* is this at"; if that ever has to relax it relaxes deliberately.
*/
unique('engagements_demand_deal_key').on(t.demandDealId),
index('engagements_status_idx').on(t.status),
],
);
export const engagementArtifacts = pgTable(
'engagement_artifacts',
{
id: uuid('id').primaryKey().defaultRandom(),
engagementId: uuid('engagement_id')
.notNull()
.references(() => engagements.id, { onDelete: 'cascade' }),
/** What it was instantiated from. Null for an artifact written from scratch. */
templateId: uuid('template_id').references(() => motionTemplates.id, { onDelete: 'set null' }),
kind: text('kind', { enum: MOTION_KINDS }).notNull(),
stage: text('stage', { enum: DEMAND_STAGES }).notNull(),
title: text('title').notNull(),
body: text('body').notNull(),
fields: jsonb('fields').$type<Record<string, unknown>>(),
status: text('status', { enum: ARTIFACT_STATUSES }).notNull().default('draft'),
authoredByUserId: uuid('authored_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
/** Set once, by promotion. Its presence is what refuses a second promotion. */
promotedTemplateId: uuid('promoted_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check('engagement_artifacts_kind_check', sql`${t.kind} IN (${inList(MOTION_KINDS)})`),
check('engagement_artifacts_stage_check', sql`${t.stage} IN (${inList(DEMAND_STAGES)})`),
check('engagement_artifacts_status_check', sql`${t.status} IN (${inList(ARTIFACT_STATUSES)})`),
/** The workspace reads one engagement's artifacts grouped by stage. */
index('engagement_artifacts_engagement_stage_idx').on(t.engagementId, t.stage),
],
);
export const qualificationScores = pgTable(
'qualification_scores',
{
id: uuid('id').primaryKey().defaultRandom(),
engagementId: uuid('engagement_id')
.notNull()
.references(() => engagements.id, { onDelete: 'cascade' }),
frameworkTemplateId: uuid('framework_template_id').references(() => motionTemplates.id, {
onDelete: 'set null',
}),
/** `MotionDimensionScore[]` exactly as scored, so an old row survives a reweighted framework. */
dimensions: jsonb('dimensions').$type<Record<string, unknown>[]>().notNull(),
/**
* Basis points of the maximum, integer, computed by
* `motionScoreBasisPoints`. Stored rather than derived because the
* framework's weights can change and this number must not.
*/
basisPoints: integer('basis_points').notNull(),
band: text('band').notNull(),
note: text('note'),
scoredByUserId: uuid('scored_by_user_id').references(() => users.id, { onDelete: 'set null' }),
scoredAt: timestamp('scored_at', { withTimezone: true }).notNull().defaultNow(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
check(
'qualification_scores_basis_points_check',
sql`${t.basisPoints} >= 0 AND ${t.basisPoints} <= ${sql.raw(String(MOTION_BASIS_POINTS_MAX))}`,
),
/** The history panel: one engagement, newest first. */
index('qualification_scores_engagement_idx').on(t.engagementId, t.scoredAt.desc()),
],
);
export type MotionTemplate = typeof motionTemplates.$inferSelect;
export type NewMotionTemplate = typeof motionTemplates.$inferInsert;
export type Engagement = typeof engagements.$inferSelect;
export type NewEngagement = typeof engagements.$inferInsert;
export type EngagementArtifact = typeof engagementArtifacts.$inferSelect;
export type NewEngagementArtifact = typeof engagementArtifacts.$inferInsert;
export type QualificationScore = typeof qualificationScores.$inferSelect;
export type NewQualificationScore = typeof qualificationScores.$inferInsert;
+67 -1
View File
@@ -7,7 +7,7 @@
* situation this command is for someone demoing on top of their own data
* and it must leave that data untouched.
*/
import { eq, inArray, like } from 'drizzle-orm';
import { eq, inArray, like, sql } from 'drizzle-orm';
import { unstampDemoActivity } from './activities';
import {
accounts,
@@ -21,8 +21,12 @@ import {
contractObligations,
contracts,
demandDeals,
engagementArtifacts,
engagements,
exportAuthorizations,
learnResources,
motionTemplates,
qualificationScores,
slaTerms,
teamMemberships,
users,
@@ -79,6 +83,68 @@ export async function clear(context: DemoContext): Promise<void> {
if (demandDealIds.length > 0) {
await db.delete(capacityRequests).where(inArray(capacityRequests.demandDealId, demandDealIds));
}
/*
* The Motion rows, before the deals they hang off.
*
* `engagements.demand_deal_id` cascades, so the delete below would take the
* engagements, their artefacts and their scores with it but not the
* promoted version 2, which is a `motion_templates` row and would be left
* behind as an orphaned version of a shipped lineage with its
* `origin_artifact_id` quietly set to null. So the promoted template goes
* first, and the three engagement tables are removed explicitly rather than
* by cascade, in child-to-parent order, so the teardown reads as what it
* does. The starter library itself is NOT touched: it is authored product
* content from the base seed, carries no prefix, and survives `--clear` the
* same way the PIG-hosted learn rows do.
*/
const demoEngagements = await db
.select({ id: engagements.id })
.from(engagements)
.where(like(engagements.summary, `${prefix}%`));
const engagementIds = demoEngagements.map((engagement) => engagement.id);
if (engagementIds.length > 0) {
/*
* Give back the `usage_count` the demo book borrowed, before the artefacts
* that justify it are deleted.
*
* Above zero, `usage_count` makes a template permanently un-editable the
* API answers 409 and tells you to cut a new version. So a teardown that
* left the counter raised would hand back a starter library three of whose
* templates can never be edited again, for engagements that no longer
* exist, and nothing in the UI would explain why. Decremented by exactly
* what the demo added rather than reset to zero, because a real
* instantiation on the same template must survive `--clear`.
*/
const borrowed = await db
.select({ templateId: engagementArtifacts.templateId })
.from(engagementArtifacts)
.where(inArray(engagementArtifacts.engagementId, engagementIds));
const usageByTemplate = new Map<string, number>();
for (const row of borrowed) {
if (row.templateId) {
usageByTemplate.set(row.templateId, (usageByTemplate.get(row.templateId) ?? 0) + 1);
}
}
for (const [templateId, count] of usageByTemplate) {
await db
.update(motionTemplates)
.set({ usageCount: sql`greatest(${motionTemplates.usageCount} - ${count}, 0)` })
.where(eq(motionTemplates.id, templateId));
}
await db
.delete(qualificationScores)
.where(inArray(qualificationScores.engagementId, engagementIds));
await db
.delete(engagementArtifacts)
.where(inArray(engagementArtifacts.engagementId, engagementIds));
await db.delete(engagements).where(inArray(engagements.id, engagementIds));
}
// After the artefacts, so `origin_artifact_id` is already gone rather than
// being set null on the way past. Scoped to the prefix, which the seeded
// library deliberately does not carry.
await db.delete(motionTemplates).where(like(motionTemplates.title, `${prefix}%`));
await db.delete(demandDeals).where(like(demandDeals.name, `${prefix}%`));
await db.delete(supplyDeals).where(like(supplyDeals.name, `${prefix}%`));
await db.delete(capacityCommitments).where(like(capacityCommitments.name, `${prefix}%`));
+12
View File
@@ -55,6 +55,7 @@ import { seedCompliance } from './compliance';
import { seedDemandPaper } from './contracts';
import { seedDemand } from './demand';
import { seedHostedLearn, seedLearn } from './learn';
import { seedMotionEngagements } from './motion';
import { seedSupply } from './supply';
const db = createDatabase();
@@ -173,6 +174,12 @@ export async function seedDemo(context: DemoContext): Promise<void> {
// because it is not demo data and must not be removed with `--clear`.
const hosted = await seedHostedLearn(context);
// After the demand book, which owns the deals both engagements hang off, and
// after the base seed has put the starter library in place — an engagement
// whose artifacts came from nothing would demonstrate the folder rather than
// the loop. Run `pnpm db:seed` before `pnpm db:demo`, as the README says.
const motion = await seedMotionEngagements(context);
const facts = await seedFacts(context);
console.log(' 5 capacity commitments (4 live, 1 lapsed), with sites, MSAs and negotiated SLAs');
@@ -197,6 +204,11 @@ export async function seedDemo(context: DemoContext): Promise<void> {
console.log(
` ${learn.total} illustrative concept videos (${learn.added} new), members-only`,
);
console.log(
` ${motion.engagements} Motion engagement(s) with ${motion.artifacts} artefact(s) and ` +
`${motion.scores} qualification score(s) — ${motion.promoted} artefact promoted back into ` +
'the library as a version 2',
);
console.log(
` ${hosted.present} PIG-hosted learn videos (${hosted.added} new)` +
`${hosted.missing > 0 ? `, ${hosted.missing} manifest entries with no file yet` : ''}`,
+472
View File
@@ -0,0 +1,472 @@
/**
* Two demo engagements, chosen so the loop is visible without a live customer.
*
* The loop is the only reason Motion exists:
*
* library template --instantiate--> engagement artifact --promote--> v2
*
* A screenshot of the library alone shows a folder of documents, which is not
* the claim. So one engagement is **mid-POC with three qualification scores**,
* because a single score is a number and three are a trajectory the movement
* is the evidence that qualification happened at all and the other is
* **closed-won with a promoted artifact**, which puts a version 2 into the
* library carrying `supersedes_id` back to the shipped version 1 and
* `origin_artifact_id` back to the engagement that proved it. Both halves of
* the FK cycle are therefore populated on a first run.
*
* Both hang off demand deals the demand book already created, rather than deals
* of their own: an engagement has no independent existence, and inventing a
* deal here would put a thirteenth row on a pipeline whose counts are quoted in
* `demo/index.ts`.
*
* The scores are computed with `motionScoreBasisPoints` rather than written as
* literals. Hard-coding 6600 would let the seed and the product disagree about
* what the same dimensions are worth, and the first place anyone would notice
* is a demo.
*/
import { motionBand, motionScoreBasisPoints, type MotionDimensionScore } from '@pig/core';
import { and, eq, like, sql } from 'drizzle-orm';
import {
demandDeals,
engagementArtifacts,
engagements,
motionTemplates,
qualificationScores,
users,
} from '../../schema/index';
import type { DemoContext } from './index';
/**
* The scorecard's dimensions and their authored weights, copied from
* `seed/motion/trainability-qualification.json`.
*
* Copied rather than read out of the seeded row, deliberately: a stored score
* is a snapshot of the framework *as it was scored*, which is the whole reason
* `qualification_scores.dimensions` holds the weights rather than a reference.
* Reading them live would make the demo history silently re-weight itself the
* day someone edits the template, which is precisely the behaviour the column
* exists to prevent.
*/
const SCORECARD_WEIGHTS: readonly { readonly id: string; readonly weight: number }[] = [
{ id: 'task_definability', weight: 10 },
{ id: 'verifier_quality', weight: 13 },
{ id: 'trace_data_rights', weight: 8 },
{ id: 'baseline_measured', weight: 8 },
{ id: 'headroom', weight: 10 },
{ id: 'env_constructibility', weight: 8 },
{ id: 'metric_owner', weight: 9 },
{ id: 'budget_source', weight: 9 },
{ id: 'exec_sponsor', weight: 4 },
{ id: 'security_legal_path', weight: 8 },
{ id: 'forcing_function', weight: 8 },
{ id: 'expansion_surface', weight: 5 },
];
/**
* Three passes over the same scorecard, in the order they were run.
*
* The trajectory is the teaching: the deal opens borderline on an unmeasured
* verifier and no named budget, the POC scoping call fixes the verifier, and
* the third pass lands once finance names a source. Two dimensions deliberately
* do NOT move `trace_data_rights` and `expansion_surface` because a
* scorecard where every number rises on every pass is a scorecard nobody is
* really filling in.
*/
const SCORE_PASSES: readonly {
readonly daysAgo: number;
readonly note: string;
readonly scores: Readonly<Record<string, number>>;
}[] = [
{
daysAgo: 52,
note:
'First pass, straight out of technical discovery. The task is well specified and there ' +
'is real headroom, but the verifier is a rubric nobody has run against human labels and ' +
'no budget line has been named. Not yet — and the two things to fix are explicit.',
scores: {
task_definability: 3,
verifier_quality: 1,
trace_data_rights: 2,
baseline_measured: 2,
headroom: 3,
env_constructibility: 2,
metric_owner: 2,
budget_source: 1,
exec_sponsor: 2,
security_legal_path: 2,
forcing_function: 2,
expansion_surface: 3,
},
},
{
daysAgo: 27,
note:
'Re-scored after the POC scoping call. They ran the judge against 300 human-labelled ' +
'items and the baseline is now measured rather than remembered. Budget is still a ' +
'reallocation nobody has signed.',
scores: {
task_definability: 3,
verifier_quality: 3,
trace_data_rights: 2,
baseline_measured: 3,
headroom: 3,
env_constructibility: 3,
metric_owner: 3,
budget_source: 1,
exec_sponsor: 2,
security_legal_path: 3,
forcing_function: 2,
expansion_surface: 3,
},
},
{
daysAgo: 6,
note:
'Third pass, in POC week two. Finance named the source and the VP Engineering owns the ' +
'metric in their own planning doc. Data rights are unchanged: the customer still cannot ' +
'export traces beyond the pilot without a DPA amendment, and that is the live risk.',
scores: {
task_definability: 4,
verifier_quality: 3,
trace_data_rights: 2,
baseline_measured: 3,
headroom: 4,
env_constructibility: 3,
metric_owner: 4,
budget_source: 3,
exec_sponsor: 3,
security_legal_path: 3,
forcing_function: 3,
expansion_surface: 3,
},
},
];
function dimensionsFor(scores: Readonly<Record<string, number>>): MotionDimensionScore[] {
return SCORECARD_WEIGHTS.map((dimension) => ({
id: dimension.id,
weight: dimension.weight,
score: scores[dimension.id] ?? 0,
}));
}
async function findDeal(context: DemoContext, name: string): Promise<string | undefined> {
const [deal] = await context.db
.select({ id: demandDeals.id })
.from(demandDeals)
.where(eq(demandDeals.name, name))
.limit(1);
return deal?.id;
}
async function findSeller(context: DemoContext, name: string): Promise<string | null> {
const [seller] = await context.db
.select({ id: users.id })
.from(users)
.where(eq(users.name, `${context.prefix}${name}`))
.limit(1);
return seller?.id ?? null;
}
async function findTemplate(
context: DemoContext,
slug: string,
): Promise<{ id: string; version: number } | undefined> {
const [template] = await context.db
.select({ id: motionTemplates.id, version: motionTemplates.version })
.from(motionTemplates)
.where(and(eq(motionTemplates.slug, slug), eq(motionTemplates.isSystem, true)))
.limit(1);
return template;
}
export async function seedMotionEngagements(
context: DemoContext,
): Promise<{ engagements: number; artifacts: number; scores: number; promoted: number }> {
const { db, prefix, at } = context;
/*
* The whole section is skipped if either engagement already exists.
*
* `engagements_demand_deal_key` would refuse a duplicate engagement, but
* nothing would refuse a second set of artifacts or worse a second
* promotion, and a second promotion writes case-study-frame v3 on top of the
* v2 this seed already created. `qualification_scores` is append-only by
* design and so has no constraint to conflict on at all; three more rows on
* every run would turn the trajectory into noise. An existence check on the
* parent is the only thing that covers all three.
*/
const existing = await db
.select({ id: engagements.id })
.from(engagements)
.where(like(engagements.summary, `${prefix}%`))
.limit(1);
if (existing.length > 0) {
return { engagements: 0, artifacts: 0, scores: 0, promoted: 0 };
}
const playbook = await findTemplate(context, 'strategic-deployment-playbook');
const scorecard = await findTemplate(context, 'trainability-qualification');
const pocTemplate = await findTemplate(context, 'post-training-poc');
const caseStudy = await findTemplate(context, 'case-study-frame');
let engagementCount = 0;
let artifactCount = 0;
let scoreCount = 0;
let promotedCount = 0;
// ------------------------------------------------- mid-POC, still scoring
const halcyonDealId = await findDeal(context, `${prefix}Managed post-training run`);
const ines = await findSeller(context, 'Ines Fabre');
/*
* A missing deal is reported, not skipped in silence.
*
* Both engagements hang off deals the demand book creates, and that book
* skips an account it has already seen so a database where the accounts
* survive but the deals were removed leaves these lookups empty and this
* whole section a no-op that reports zeroes and looks like a bug in the
* loader. It was found exactly that way, on a shared development database.
*/
if (!halcyonDealId) {
console.warn(
` No "${prefix}Managed post-training run" deal — skipping the mid-POC engagement. ` +
'Run `pnpm db:demo -- --clear` and reseed to rebuild the demand book.',
);
}
if (halcyonDealId) {
const [engagement] = await db
.insert(engagements)
.values({
demandDealId: halcyonDealId,
playbookTemplateId: playbook?.id ?? null,
ownerUserId: ines,
status: 'open',
summary: `${prefix}Post-training motion on the Halcyon summarisation workflow — POC week two.`,
openedAt: at(-58),
})
.returning({ id: engagements.id });
if (engagement) {
engagementCount += 1;
await db.insert(engagementArtifacts).values({
engagementId: engagement.id,
templateId: scorecard?.id ?? null,
kind: 'discovery',
stage: 'qualification',
title: `${prefix}Discovery notes — Halcyon summarisation`,
body:
'## What they are trying to do\n\n' +
'Reduce reviewer time on internal research summaries. Today a senior analyst reads a ' +
'40-page source and writes a one-page brief; the bar is "would the head of research ' +
'send this out unedited".\n\n' +
'## Unit of work\n\n' +
'One source document in, one brief out. They have 4,100 historical pairs and the ' +
'reviewer decision on each.\n\n' +
'## Verifier\n\n' +
'A model judge calibrated against 300 of those human decisions. Agreement is 0.81 ' +
'Cohens kappa, measured on a held-out slice rather than the calibration set.\n\n' +
'## The live risk\n\n' +
'Trace export beyond the pilot needs a DPA amendment their counsel has not seen yet.',
status: 'final',
authoredByUserId: ines,
fields: { source: 'Technical discovery call, plus the follow-up with the data owner.' },
});
await db.insert(engagementArtifacts).values({
engagementId: engagement.id,
templateId: pocTemplate?.id ?? null,
kind: 'poc',
stage: 'poc',
title: `${prefix}POC plan — summarisation reward model`,
body:
'## Success criterion, agreed in writing\n\n' +
'Judge-scored acceptance rate on a held-out set of 400 briefs rises from the measured ' +
'baseline of 61% to at least 78%, at no more than 1.4× the current inference cost ' +
'per brief.\n\n' +
'## What we run\n\n' +
'Two weeks on 32× H100. Week one builds the environment and reproduces the ' +
'baseline; week two is the training run and the evaluation.\n\n' +
'## What we hand back\n\n' +
'The environment, the eval harness, and the number — whichever way it comes out. ' +
'A POC that reports a failure honestly is what makes the next number believable.\n\n' +
'## Open\n\n' +
'Trace rights beyond the pilot. Blocked on the DPA amendment.',
status: 'review',
authoredByUserId: ines,
fields: { baselineAcceptancePct: 61, targetAcceptancePct: 78, gpuCount: 32, weeks: 2 },
});
artifactCount += 2;
for (const pass of SCORE_PASSES) {
const dimensions = dimensionsFor(pass.scores);
const basisPoints = motionScoreBasisPoints(dimensions);
await db.insert(qualificationScores).values({
engagementId: engagement.id,
frameworkTemplateId: scorecard?.id ?? null,
// The column is `Record<string, unknown>[]`; Drizzle's jsonb `$type`
// will not take an interface with readonly properties, and widening
// the column to suit the seed would lose the shape everywhere else.
dimensions: dimensions as unknown as Record<string, unknown>[],
basisPoints,
band: motionBand(basisPoints).label,
note: `${prefix}${pass.note}`,
scoredByUserId: ines,
scoredAt: at(-pass.daysAgo),
});
scoreCount += 1;
}
}
}
// ------------------------------------- closed won, and promoted afterwards
const northwindDealId = await findDeal(context, `${prefix}A100 inference pilot`);
const marcus = await findSeller(context, 'Marcus Oyelaran');
if (!northwindDealId) {
console.warn(
` No "${prefix}A100 inference pilot" deal — skipping the closed-won engagement, and ` +
'with it the promotion that demonstrates the loop.',
);
}
if (northwindDealId) {
const [engagement] = await db
.insert(engagements)
.values({
demandDealId: northwindDealId,
playbookTemplateId: playbook?.id ?? null,
ownerUserId: marcus,
status: 'won',
summary: `${prefix}Northwind inference pilot — won, and written up.`,
openedAt: at(-190),
closedAt: at(-96),
})
.returning({ id: engagements.id });
if (engagement) {
engagementCount += 1;
const [artifact] = await db
.insert(engagementArtifacts)
.values({
engagementId: engagement.id,
templateId: caseStudy?.id ?? null,
kind: 'case_study',
stage: 'expansion',
title: `${prefix}Northwind Robotics — A100 inference pilot`,
body:
'## The problem\n\n' +
'Northwind ran perception inference on reserved A100s they had bought for training, ' +
'and were paying training prices for a serving workload with a 40:1 peak-to-trough ' +
'shape.\n\n' +
'## What we did\n\n' +
'Moved the trough onto committed capacity and the peak onto burst, with the split ' +
'set by their own 90-day request trace rather than by a headline number.\n\n' +
'## The number\n\n' +
'Cost per million inferences fell 34%. Measured against their own billing, over a ' +
'full quarter, not against a list price.\n\n' +
'## What made it work, and what to reuse\n\n' +
'They had the request trace. Every engagement since has asked for it in the first ' +
'call, which is the change this write-up put into the frame itself.',
status: 'final',
authoredByUserId: marcus,
fields: {
metric: 'Cost per million inferences',
improvementPct: 34,
window: 'One quarter, customer billing',
approvedForExternalUse: false,
},
})
.returning({ id: engagementArtifacts.id, title: engagementArtifacts.title });
artifactCount += 1;
/*
* The promotion, written by hand rather than by calling the API service:
* the seed has no HTTP surface and no principal. It follows the same
* rules the service does version = previous + 1, `supersedesId` to the
* shipped v1, `originArtifactId` to the artifact, `visibility: 'shared'`,
* `isSystem: false` because a demo that produced a row the real path
* could not have produced would teach the wrong shape of the table.
*
* `isSystem` is false and the title is prefixed, which is what lets
* `clear()` find it. It is a version 2 of a shipped lineage, so removing
* the demo book genuinely returns the library to what ships.
*/
if (artifact && caseStudy) {
const [promoted] = await db
.insert(motionTemplates)
.values({
kind: 'case_study',
stage: 'expansion',
slug: 'case-study-frame',
version: caseStudy.version + 1,
title: `${prefix}Case Study Frame — ask for the request trace first`,
summary:
'Version 2, promoted out of the Northwind pilot write-up. Adds the one question ' +
'that made that engagement measurable: get the customers own usage trace ' +
'before anyone quotes a saving.',
body:
'_Promoted from an engagement. The change from version 1 is the section below._\n\n' +
'## Before you frame anything: get the trace\n\n' +
'A case study is only as good as the baseline it is measured against, and the ' +
'only baseline a customer cannot argue with afterwards is their own telemetry. ' +
'Ask for it in the first call, not at write-up time.\n\n' +
'## Everything else\n\n' +
'As version 1: the problem, what we did, the number, what to reuse.',
fields: {
promotedFrom: artifact.title,
addedSection: 'Before you frame anything: get the trace',
},
visibility: 'shared',
ownerUserId: marcus,
supersedesId: caseStudy.id,
originArtifactId: artifact.id,
isSystem: false,
})
.onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] })
.returning({ id: motionTemplates.id });
if (promoted) {
await db
.update(engagementArtifacts)
.set({ promotedTemplateId: promoted.id })
.where(eq(engagementArtifacts.id, artifact.id));
promotedCount += 1;
}
}
}
}
/*
* `usage_count` is what the API reads to refuse a `PATCH` on a used
* template, so the demo must move it or a library where three templates have
* visibly been instantiated still reads as never used and the rule the
* whole feature turns on would be undemonstrable. Incremented in one sweep at
* the end rather than per insert, so the count is derived from the artefacts
* that actually landed and `clear()` can undo it by counting the same rows.
*/
const instantiated = await db
.select({ templateId: engagementArtifacts.templateId })
.from(engagementArtifacts)
.where(like(engagementArtifacts.title, `${prefix}%`));
const usageByTemplate = new Map<string, number>();
for (const row of instantiated) {
if (row.templateId) usageByTemplate.set(row.templateId, (usageByTemplate.get(row.templateId) ?? 0) + 1);
}
for (const [templateId, count] of usageByTemplate) {
await db
.update(motionTemplates)
.set({ usageCount: sql`${motionTemplates.usageCount} + ${count}` })
.where(eq(motionTemplates.id, templateId));
}
return {
engagements: engagementCount,
artifacts: artifactCount,
scores: scoreCount,
promoted: promotedCount,
};
}
+14
View File
@@ -14,6 +14,9 @@
* **A worked example** of the thing PIG exists for: one capacity
* commitment, two allocations against it, and therefore a real margin
* number and a real idle-capacity alert on the dashboard.
* **The Motion starter library.** Authored content shipped with the
* product rather than invented data, which is why it lives here and not in
* `demo/`. See `motion/index.ts`.
*
* The example is clearly labelled, and the company buying is invented. Real
* named companies appear here only with a source; commercial terms attached to
@@ -31,6 +34,7 @@ import {
teamMemberships,
users,
} from '../schema/index';
import { seedMotionLibrary } from './motion/index';
import { PRIME_INTELLECT_PEOPLE, PUBLIC_CUSTOMER_REFERENCES, UNRESOLVED_NAMES } from './people';
const db = createDatabase();
@@ -343,6 +347,16 @@ async function seed() {
console.log(' Worked example already present — skipped.');
}
// ------------------------------------------------ the Motion starter library
const motion = await seedMotionLibrary(db);
console.log(
` ${motion.total} Motion starter template(s) (${motion.added} new) — authored content, ` +
'shared and system-owned, version 1 of their lineages.',
);
for (const bad of motion.rejected) {
console.error(` SKIPPED a starter template with a value outside the ontology: ${bad}`);
}
// ---------------------------------------------------------- the dev user
//
// Only when the table is empty. With authentication disabled in development
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+178
View File
@@ -0,0 +1,178 @@
/**
* The starter library twelve authored templates, shipped with the product.
*
* These are **not** demo data and carry no `DEMO — ` prefix. They are written
* content in the same class as `docs/learn-scripts.md`: a customer who
* self-hosts PIG gets them, uses them against real deals, and promotes their
* own versions back over the top. That is why they seed `isSystem: true`,
* `visibility: 'shared'`, `version: 1` and `ownerUserId: null` a shared
* system row belongs to the deployment rather than to whoever ran the seed,
* and version 1 is the base of a lineage that a customer's promotions extend.
*
* The prose lives in JSON beside this file rather than in a TypeScript literal
* because the bodies are long-form markdown the shortest is 7kB and the
* longest 24kB and a 200kB module of backtick strings is unreviewable and
* unmergeable by two people at once. They are imported statically with an
* import attribute rather than read with `readdir`, which was tried first: a
* static import makes a missing or renamed file a typecheck failure instead of
* a seed that quietly ships eleven templates. Both `tsx` and `tsc` were run
* against this to confirm the attribute syntax is understood by each, since
* the server runs the TypeScript directly.
*
* Idempotency is `onConflictDoNothing({ target: [slug, version] })`, which
* works **only** because of `motion_templates_slug_version_key`. AGENTS.md §5
* is blunt about what that clause does without a constraint to fire on it
* silently duplicated seed data here twice so `assertNoDuplicates` below
* checks the outcome rather than trusting the schema, and CI counts this table
* in its idempotency gate alongside `contacts`.
*/
import { DEMAND_STAGES, isMotionKind, type DemandStage, type MotionKind } from '@pig/core';
import { and, eq, inArray } from 'drizzle-orm';
import type { Database } from '../../client';
import { motionTemplates } from '../../schema/index';
import caseStudyFrame from './case-study-frame.json' with { type: 'json' };
import dataAndSecurityBrief from './data-and-security-brief.json' with { type: 'json' };
import postTrainingPoc from './post-training-poc.json' with { type: 'json' };
import pricingAndPackaging from './pricing-and-packaging.json' with { type: 'json' };
import procurementAndBudgetPath from './procurement-and-budget-path.json' with { type: 'json' };
import productionReadiness from './production-readiness.json' with { type: 'json' };
import proposalBlocks from './proposal-blocks.json' with { type: 'json' };
import referenceArchitectures from './reference-architectures.json' with { type: 'json' };
import strategicDeploymentPlaybook from './strategic-deployment-playbook.json' with { type: 'json' };
import technicalDiscovery from './technical-discovery.json' with { type: 'json' };
import technicalNarratives from './technical-narratives.json' with { type: 'json' };
import trainabilityQualification from './trainability-qualification.json' with { type: 'json' };
/**
* The authored shape. `kind` and `stage` are `string` here and narrowed at the
* boundary below, because `resolveJsonModule` infers `string` for a JSON string
* and there is no honest way to tell TypeScript otherwise without a cast that
* would also swallow a genuine typo in the file.
*/
interface StarterTemplate {
readonly kind: string;
readonly slug: string;
readonly title: string;
readonly summary: string;
readonly stage: string;
readonly body: string;
readonly fields: Record<string, unknown>;
/** Guidance for whoever runs it. Folded into `fields` — see `toRow`. */
readonly notes: string;
}
const STARTER_LIBRARY: readonly StarterTemplate[] = [
technicalDiscovery,
dataAndSecurityBrief,
trainabilityQualification,
referenceArchitectures,
technicalNarratives,
postTrainingPoc,
proposalBlocks,
pricingAndPackaging,
procurementAndBudgetPath,
productionReadiness,
caseStudyFrame,
strategicDeploymentPlaybook,
];
const isDemandStage = (value: string): value is DemandStage =>
(DEMAND_STAGES as readonly string[]).includes(value);
/**
* `notes` has no column, deliberately.
*
* It is guidance for the person running the template when to reach for it,
* what it is not for rather than a field the artifact renders, so a column
* would put it on every engagement artifact's editor for no reason. Keeping it
* inside `fields` means promotion carries it forward with the rest of the
* structured payload without any special handling in the promote path.
*/
function toRow(
template: StarterTemplate,
kind: MotionKind,
stage: DemandStage,
): typeof motionTemplates.$inferInsert {
return {
kind,
stage,
slug: template.slug,
version: 1,
title: template.title,
summary: template.summary,
body: template.body,
fields: { ...template.fields, notes: template.notes },
visibility: 'shared',
ownerUserId: null,
isSystem: true,
};
}
/**
* Confirm the outcome, not the constraint.
*
* Reading `pg_constraint` would prove the unique index exists; counting the
* rows proves the clause did its job, which is the thing that has actually
* gone wrong here before. It throws rather than warns: a duplicated starter
* library is a corrupt library, and every subsequent run would double it again.
*
* Scoped to version 1, and found by running it: the demo book promotes an
* artifact into `case-study-frame` v2, so counting every row for these slugs
* reported the loop working as a duplicated seed. Version 1 is the only row
* this loader writes and therefore the only one it may assert about.
*/
async function assertNoDuplicates(db: Database, slugs: readonly string[]): Promise<number> {
const rows = await db
.select({ slug: motionTemplates.slug })
.from(motionTemplates)
.where(and(inArray(motionTemplates.slug, [...slugs]), eq(motionTemplates.version, 1)));
if (rows.length > slugs.length) {
const counts = new Map<string, number>();
for (const row of rows) counts.set(row.slug, (counts.get(row.slug) ?? 0) + 1);
const duplicated = [...counts.entries()].filter(([, count]) => count > 1).map(([slug]) => slug);
throw new Error(
`The starter library has duplicate rows for: ${duplicated.join(', ')}. ` +
'The unique constraint motion_templates_slug_version_key is missing or the ' +
'conflict target no longer matches it — see AGENTS.md §5.',
);
}
return rows.length;
}
export async function seedMotionLibrary(
db: Database,
): Promise<{ total: number; added: number; rejected: readonly string[] }> {
const rejected: string[] = [];
let added = 0;
for (const template of STARTER_LIBRARY) {
/*
* A bad `kind` or `stage` is skipped, not thrown on. These files are
* authored by hand and the CHECK constraints would reject the row anyway
* but as an aborted transaction partway through the base seed, taking the
* eleven good templates and everything after them down with it. Reporting
* the one bad file by name is more useful and leaves the deployment usable.
*/
if (!isMotionKind(template.kind) || !isDemandStage(template.stage)) {
rejected.push(`${template.slug} (kind=${template.kind}, stage=${template.stage})`);
continue;
}
const [created] = await db
.insert(motionTemplates)
.values(toRow(template, template.kind, template.stage))
.onConflictDoNothing({ target: [motionTemplates.slug, motionTemplates.version] })
.returning({ id: motionTemplates.id });
if (created) added += 1;
}
const total = await assertNoDuplicates(
db,
STARTER_LIBRARY.map((template) => template.slug),
);
return { total, added, rejected };
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30
View File
@@ -177,9 +177,15 @@ importers:
react-hook-form:
specifier: ^7.85.0
version: 7.85.0(react@19.2.8)
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.18)(react@19.2.8)
react-router-dom:
specifier: ^7.1.1
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
sonner:
specifier: ^2.0.8
version: 2.0.8(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -3156,6 +3162,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
'@types/react': '>=18'
react: '>=18'
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -6412,6 +6424,24 @@ snapshots:
dependencies:
react: 19.2.8
react-markdown@10.1.0(@types/react@19.2.18)(react@19.2.8):
dependencies:
'@types/hast': 3.0.5
'@types/mdast': 4.0.4
'@types/react': 19.2.18
devlop: 1.1.0
hast-util-to-jsx-runtime: 2.3.6
html-url-attributes: 3.0.1
mdast-util-to-hast: 13.2.1
react: 19.2.8
remark-parse: 11.0.0
remark-rehype: 11.1.2
unified: 11.0.5
unist-util-visit: 5.1.0
vfile: 6.0.3
transitivePeerDependencies:
- supports-color
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.18)(react@19.2.8):