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
+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
// ---------------------------------------------------------------------------