diff --git a/src/friction/format.ts b/src/friction/format.ts index 977684e5..3c5d8c6f 100644 --- a/src/friction/format.ts +++ b/src/friction/format.ts @@ -17,77 +17,102 @@ function maybeLink(label: string, url: string | undefined): string { return url ? `[${label}](${url})` : label; } -function projectContextLines(report: FrictionReport): string[] { - const { project } = report.context; - return [ - `- Project: ${valueOrMissing(project.name ?? project.id)} (${project.id})`, - `- PM provider: ${valueOrMissing(project.pmType)}`, - ...(project.repo ? [`- Repository: ${project.repo}`] : []), - ]; +function formatPRLabel(pr: NonNullable): string { + if (!pr.number) return valueOrMissing(pr.title); + return `#${pr.number}${pr.title ? ` ${pr.title}` : ''}`; } -function optionalContextLines(report: FrictionReport): string[] { - const { agent, run, workItem, pr } = report.context; +/** + * Compact run-context bullets for the friction card body. + * + * Optimized for the operator triaging the card: bold-keyed, dense, key + * facts inline. Mirrors the field-list style of the Sentry alert formatter + * (`src/integrations/alerting/_shared/format.ts:formatSentryCardBody`). + * + * Order is run-first because the run URL is the GOLD piece — it links to + * the full agent transcript (`cascade runs logs ` and the LLM-call + * stream). Work item, PR, project, and whileDoing follow. Lines for absent + * fields are dropped entirely; no `_not provided_` placeholders for + * conditional context (we keep that placeholder only inside line text where + * one fragment of an otherwise-present line is missing). + */ +function runContextLines(report: FrictionReport): string[] { + const { project, agent, run, workItem, pr } = report.context; const lines: string[] = []; - if (agent) { - const agentParts = [ - agent.type, - agent.engine && `engine=${agent.engine}`, - agent.model && `model=${agent.model}`, - ].filter(Boolean); - lines.push(`- Agent: ${agentParts.join(' - ')}`); + if (run) { + const engineModel = + agent?.engine && agent?.model + ? `${agent.engine}/${agent.model}` + : (agent?.engine ?? agent?.model); + const meta = [agent?.type, engineModel].filter(Boolean).join(' · '); + const runLabel = maybeLink(valueOrMissing(run.id), run.url); + lines.push(`- **Run:** ${runLabel}${meta ? ` — ${meta}` : ''}`); } - if (run) lines.push(`- Run: ${maybeLink(valueOrMissing(run.id), run.url)}`); - if (run?.startedAt) lines.push(`- Run started: ${run.startedAt}`); + if (workItem) { - const label = workItem.title - ? `${workItem.title} (${valueOrMissing(workItem.id)})` - : valueOrMissing(workItem.id); - lines.push(`- Work item: ${maybeLink(label, workItem.url)}`); + const idSuffix = workItem.id ? ` (\`${workItem.id}\`)` : ''; + const label = workItem.title ? `${workItem.title}${idSuffix}` : valueOrMissing(workItem.id); + lines.push(`- **Work item:** ${maybeLink(label, workItem.url)}`); } - if (pr) lines.push(`- Pull request: ${maybeLink(formatPRLabel(pr), pr.url)}`); - if (pr?.branch) lines.push(`- PR branch: ${pr.branch}`); - if (pr?.headSha) lines.push(`- PR head SHA: ${pr.headSha}`); - return lines; -} + if (pr) { + const branch = pr.branch ? ` — \`${pr.branch}\`` : ''; + const sha = pr.headSha ? ` @ \`${pr.headSha.slice(0, 12)}\`` : ''; + lines.push(`- **PR:** ${maybeLink(formatPRLabel(pr), pr.url)}${branch}${sha}`); + } -function formatPRLabel(pr: NonNullable): string { - if (!pr.number) return valueOrMissing(pr.title); - return `#${pr.number}${pr.title ? ` ${pr.title}` : ''}`; -} + const repoSuffix = project.repo ? ` — ${project.repo}` : ''; + const pmSuffix = project.pmType ? ` (${project.pmType})` : ''; + lines.push(`- **Project:** \`${project.id}\`${repoSuffix}${pmSuffix}`); + + lines.push(`- **While doing:** ${report.whileDoing}`); -function contextLines(report: FrictionReport): string[] { - return [...projectContextLines(report), ...optionalContextLines(report)]; + return lines; } +/** + * Render the FrictionReport into the title + descriptionMarkdown that the + * PM materializer feeds to `provider.createWorkItem`. + * + * Title: `[Friction · · ] `. Surfaces all + * three classification facets at the top of operator triage views and + * produces clean hyphenated slugs (`friction-tooling-low-...`) — the + * earlier `[Friction][low]` form concatenated to ugly `frictionlow-...` + * because the brackets had no separator. + * + * Body has two semantic sections: + * - `## Details` (agent's free-form prose, verbatim — most worth reading) + * - `## Run context` (compact bold-keyed bullets — what operator needs to triage) + * - italic `_Reported _` footer (machine-time precision; PM provider's + * native createdAt already surfaces this so no need for a section header) + * + * Removed (vs the prior format) — all redundant with content already + * surfaced elsewhere: + * - `## What happened` + summary (title carries summary) + * - `## Classification` block (category/severity in title; whileDoing + * migrated to run-context) + * - `## Timestamp` header (provider's native field already shows this) + */ export function formatFrictionReport( report: FrictionReport, now: Date = new Date(), ): FormattedFrictionReport { const timestamp = report.createdAt ?? now.toISOString(); - const title = truncateTitle(`[Friction][${report.severity}] ${report.summary}`); + const title = truncateTitle( + `[Friction · ${report.category} · ${report.severity}] ${report.summary}`, + ); return { title, descriptionMarkdown: [ - '## What happened', - report.summary, - '', '## Details', report.details, '', - '## Classification', - `- Category: ${report.category}`, - `- Severity: ${report.severity}`, - `- While doing: ${report.whileDoing}`, - '', - '## Context', - ...contextLines(report), + '## Run context', + ...runContextLines(report), '', - '## Timestamp', - timestamp, + `_Reported ${timestamp}_`, ].join('\n'), }; } diff --git a/src/friction/materialize.ts b/src/friction/materialize.ts index c7dda589..d96bba91 100644 --- a/src/friction/materialize.ts +++ b/src/friction/materialize.ts @@ -1,4 +1,8 @@ -import { getFrictionContainerId, getFrictionStatusDestination } from '../pm/config.js'; +import { + getFrictionContainerId, + getFrictionLabelId, + getFrictionStatusDestination, +} from '../pm/config.js'; import { pmRegistry } from '../pm/registry.js'; import type { ProjectConfig } from '../types/index.js'; import { formatFrictionReport } from './format.js'; @@ -29,11 +33,18 @@ export async function materializeFrictionReport({ const provider = pmRegistry.createProvider(project); const formatted = formatFrictionReport(report, now); + // Apply the optional `cascade-friction` label when configured. Mirrors + // the spec-019 `cascade-alert` opt-in pattern: operators add the label + // to PM integration config when they want filtering/clustering on + // friction cards; absent config means cards file unlabeled and behavior + // is unchanged from the prior release. + const labelId = getFrictionLabelId(project); + const labels = labelId ? [labelId] : []; const workItem = await provider.createWorkItem({ containerId, title: formatted.title, description: formatted.descriptionMarkdown, - labels: [], + labels, }); const destination = getFrictionStatusDestination(project); diff --git a/src/integrations/pm/jira/config-schema.ts b/src/integrations/pm/jira/config-schema.ts index d6630111..174a90b5 100644 --- a/src/integrations/pm/jira/config-schema.ts +++ b/src/integrations/pm/jira/config-schema.ts @@ -48,6 +48,7 @@ export const jiraConfigSchema = z * is present in the input. * * `cascadeAlert` — recognized label for alert work items (spec 019). + * `cascadeFriction` — recognized label for friction work items (2026-05-10). * `statuses.alerts` is the recognized status key for the alerts slot. * `statuses.friction` is the recognized status key for the friction report slot. */ @@ -58,6 +59,7 @@ export const jiraConfigSchema = z error: z.string().default('cascade-error'), readyToProcess: z.string().default('cascade-ready'), cascadeAlert: z.string().optional(), + cascadeFriction: z.string().optional(), }) .optional(), }) diff --git a/src/integrations/pm/linear/config-schema.ts b/src/integrations/pm/linear/config-schema.ts index 9d9605e6..71e8524d 100644 --- a/src/integrations/pm/linear/config-schema.ts +++ b/src/integrations/pm/linear/config-schema.ts @@ -40,6 +40,7 @@ export const linearConfigSchema = z * optional to accommodate teams that only use a subset. * * `cascadeAlert` — recognized label UUID for alert work items (spec 019). + * `cascadeFriction` — recognized label UUID for friction work items (2026-05-10). * `statuses.alerts` is the recognized status key for the alerts slot. * `statuses.friction` is the recognized status key for the friction report slot. */ @@ -51,6 +52,7 @@ export const linearConfigSchema = z readyToProcess: z.string().optional(), auto: z.string().optional(), cascadeAlert: z.string().optional(), + cascadeFriction: z.string().optional(), }) .optional(), diff --git a/src/pm/config.ts b/src/pm/config.ts index 38cc1bbf..3810ef39 100644 --- a/src/pm/config.ts +++ b/src/pm/config.ts @@ -31,6 +31,8 @@ export interface JiraConfig { auto?: string; /** JIRA label name applied to alert work items (spec 019). */ cascadeAlert?: string; + /** JIRA label name applied to friction work items (2026-05-10). */ + cascadeFriction?: string; }; } @@ -69,6 +71,8 @@ export interface LinearConfig { auto?: string; /** Linear label UUID applied to alert work items (spec 019). */ cascadeAlert?: string; + /** Linear label UUID applied to friction work items (2026-05-10). */ + cascadeFriction?: string; }; customFields?: { cost?: string }; } @@ -184,6 +188,31 @@ export function getAlertLabelId(project: ProjectConfig): string | undefined { return undefined; } +/** + * Returns the label identifier to apply to a friction work item: + * - Trello → `labels['cascade-friction']` (Trello label ID) + * - JIRA → `labels.cascadeFriction` (JIRA label name string) + * - Linear → `labels.cascadeFriction` (Linear label UUID) + * + * Returns `undefined` when the label slot is not configured. Mirrors the + * `getAlertLabelId` opt-in pattern from spec 019: operators add the label + * to PM integration config when they want filtering/clustering on friction + * cards; absent config means cards file unlabeled and behavior is unchanged. + */ +export function getFrictionLabelId(project: ProjectConfig): string | undefined { + const pmType = project.pm?.type; + if (pmType === 'trello') { + return getTrelloConfig(project)?.labels?.['cascade-friction']; + } + if (pmType === 'jira') { + return getJiraConfig(project)?.labels?.cascadeFriction; + } + if (pmType === 'linear') { + return getLinearConfig(project)?.labels?.cascadeFriction; + } + return undefined; +} + /** * Returns the literal `'alerts'` status key when the project's PM config * has the alerts slot populated, otherwise `undefined`. diff --git a/tests/unit/friction/format.test.ts b/tests/unit/friction/format.test.ts index 088589f5..f1b5b915 100644 --- a/tests/unit/friction/format.test.ts +++ b/tests/unit/friction/format.test.ts @@ -33,7 +33,7 @@ function makeReport(overrides: Partial = {}): FrictionReport { title: 'feat: example', url: 'https://github.com/acme/cascade/pull/12', branch: 'feature/example', - headSha: 'abc123', + headSha: 'abc123def4567890', }, }, ...overrides, @@ -41,45 +41,109 @@ function makeReport(overrides: Partial = {}): FrictionReport { } describe('formatFrictionReport', () => { - it('produces a PM-ready title and markdown body with runtime context', () => { + // 2026-05-10 rewrite: title now surfaces all three classification facets + // (Friction · category · severity) inside a single bracket pair so PM + // systems slugify it cleanly (e.g. `friction-pm-data-medium-...`). The + // prior `[Friction][medium]` form concatenated to ugly `frictionmedium-...` + // because the brackets had no separator. Body dropped the redundant + // `## What happened` (title carries summary), `## Classification` (category + // + severity in title; whileDoing migrated into Run context), and + // `## Timestamp` header (italic footer instead — provider's createdAt + // already surfaces this). + it('title surfaces Friction · category · severity in a single bracket pair', () => { const formatted = formatFrictionReport(makeReport(), new Date('2026-05-09T18:00:00.000Z')); expect(formatted.title).toBe( - '[Friction][medium] Could not inspect an authenticated Trello attachment', + '[Friction · pm-data · medium] Could not inspect an authenticated Trello attachment', ); - expect(formatted.descriptionMarkdown).toContain('## What happened'); - expect(formatted.descriptionMarkdown).toContain( + // Pin the absence of the prior bracket-concat form so reverts fail loudly. + expect(formatted.title).not.toContain('[Friction][medium]'); + }); + + it('body has only `## Details` and `## Run context` sections plus an italic timestamp footer', () => { + const md = formatFrictionReport( + makeReport(), + new Date('2026-05-09T18:00:00.000Z'), + ).descriptionMarkdown; + + // Two — and ONLY two — markdown headings. + const headings = md.split('\n').filter((l) => l.startsWith('## ')); + expect(headings).toEqual(['## Details', '## Run context']); + + // Removed sections — pin loudly so a partial revert fails this test. + expect(md).not.toContain('## What happened'); + expect(md).not.toContain('## Classification'); + expect(md).not.toContain('## Timestamp'); + expect(md).not.toContain('- Category:'); + expect(md).not.toContain('- Severity:'); + + // Details section carries the agent's verbatim prose. + expect(md).toContain('## Details'); + expect(md).toContain( 'The original attachment URL returned an authorization error during analysis.', ); - expect(formatted.descriptionMarkdown).toContain('- Category: pm-data'); - expect(formatted.descriptionMarkdown).toContain( - '- While doing: reviewing work item screenshots', - ); - expect(formatted.descriptionMarkdown).toContain('- Project: Cascade (proj-1)'); - expect(formatted.descriptionMarkdown).toContain( - '- Run: [run-1](https://ca.sca.de.com/runs/run-1)', + + // Italic timestamp footer (not a section header). + expect(md.trim().endsWith('_Reported 2026-05-09T18:00:00.000Z_')).toBe(true); + }); + + it('Run context renders compact bold-keyed bullets — Run / Work item / PR / Project / While doing', () => { + const md = formatFrictionReport( + makeReport(), + new Date('2026-05-09T18:00:00.000Z'), + ).descriptionMarkdown; + + // Run line: link + agent type + engine/model meta. + expect(md).toContain( + '- **Run:** [run-1](https://ca.sca.de.com/runs/run-1) — implementation · codex/gpt-5.4', ); - expect(formatted.descriptionMarkdown).toContain( - '- Work item: [Add friction reports (card-1)](https://trello.com/c/card-1)', + // Work item with bold key, title, monospaced id, and link. + expect(md).toContain( + '- **Work item:** [Add friction reports (`card-1`)](https://trello.com/c/card-1)', ); - expect(formatted.descriptionMarkdown).toContain( - '- Pull request: [#12 feat: example](https://github.com/acme/cascade/pull/12)', + // PR line: branch + 12-char head SHA inline. + expect(md).toContain( + '- **PR:** [#12 feat: example](https://github.com/acme/cascade/pull/12) — `feature/example` @ `abc123def456`', ); - expect(formatted.descriptionMarkdown).toContain('2026-05-09T18:00:00.000Z'); - expect(formatted.descriptionMarkdown).not.toMatch(/resolution plan/i); + // Project — single dense line with id, repo, and pm type. + expect(md).toContain('- **Project:** `proj-1` — acme/cascade (trello)'); + // While doing migrated into run context. + expect(md).toContain('- **While doing:** reviewing work item screenshots'); + }); + + it('drops PR / Work item lines entirely when the report has no PR or work item context', () => { + const md = formatFrictionReport( + makeReport({ + context: { + project: { id: 'proj-1', pmType: 'trello' }, + agent: { type: 'planning', engine: 'codex', model: 'gpt-5.4' }, + run: { id: 'run-1', url: 'https://ca.sca.de.com/runs/run-1' }, + }, + }), + new Date('2026-05-09T18:00:00.000Z'), + ).descriptionMarkdown; + + // No placeholders for absent context; lines just don't render. + expect(md).not.toContain('**Work item:**'); + expect(md).not.toContain('**PR:**'); + expect(md).not.toContain('_not provided_'); + // Run + Project + While doing still present. + expect(md).toContain('**Run:**'); + expect(md).toContain('**Project:**'); + expect(md).toContain('**While doing:**'); }); - it('prefers report.createdAt over the formatter clock', () => { + it('prefers report.createdAt over the formatter clock for the italic timestamp footer', () => { const formatted = formatFrictionReport( makeReport({ createdAt: '2026-05-09T17:00:00.000Z' }), new Date('2026-05-09T18:00:00.000Z'), ); - expect(formatted.descriptionMarkdown).toContain('2026-05-09T17:00:00.000Z'); + expect(formatted.descriptionMarkdown).toContain('_Reported 2026-05-09T17:00:00.000Z_'); expect(formatted.descriptionMarkdown).not.toContain('2026-05-09T18:00:00.000Z'); }); - it('truncates long PM titles', () => { + it('truncates long PM titles to 120 chars with an ellipsis', () => { const formatted = formatFrictionReport( makeReport({ summary: 'x'.repeat(200), severity: 'high' }), new Date('2026-05-09T18:00:00.000Z'), diff --git a/tests/unit/friction/materialize.test.ts b/tests/unit/friction/materialize.test.ts index bc159de6..acb462e4 100644 --- a/tests/unit/friction/materialize.test.ts +++ b/tests/unit/friction/materialize.test.ts @@ -95,11 +95,14 @@ describe('materializeFrictionReport', () => { expect(mockCreateWorkItem).toHaveBeenCalledWith( expect.objectContaining({ containerId: 'CAS', - title: '[Friction][low] Tool failed while reading logs', + // 2026-05-10: title surfaces all three classification facets + // inside a single bracket pair (was '[Friction][low] ...'). + title: '[Friction · tooling · low] Tool failed while reading logs', labels: [], }), ); - expect(mockCreateWorkItem.mock.calls[0][0].description).toContain('## Context'); + // Body now uses the compact Run context block (was '## Context'). + expect(mockCreateWorkItem.mock.calls[0][0].description).toContain('## Run context'); expect(mockMoveWorkItem).toHaveBeenCalledWith('friction-card-1', 'Friction'); }); @@ -128,6 +131,44 @@ describe('materializeFrictionReport', () => { expect(mockMoveWorkItem).not.toHaveBeenCalled(); }); + // 2026-05-10: opt-in label is applied at materialize time when configured. + // Mirrors spec-019 cascade-alert pattern. Operators add labels.cascadeFriction + // (JIRA/Linear) or labels['cascade-friction'] (Trello) to enable filtering. + it('applies the cascade-friction label on Trello when labels[cascade-friction] is configured', async () => { + const project = makeTrelloProject(); + (project.trello as { labels: Record }).labels = { + 'cascade-friction': 'trello-label-friction', + }; + + await materializeFrictionReport({ project, report: makeReport() }); + + expect(mockCreateWorkItem).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['trello-label-friction'] }), + ); + }); + + it('applies the cascade-friction label on JIRA when labels.cascadeFriction is configured', async () => { + const project = makeJiraProject(); + (project.jira as { labels: Record }).labels = { + cascadeFriction: 'cascade-friction', + }; + + await materializeFrictionReport({ project, report: makeReport() }); + + expect(mockCreateWorkItem).toHaveBeenCalledWith( + expect.objectContaining({ labels: ['cascade-friction'] }), + ); + }); + + it('files unlabeled cards when the cascade-friction label is absent (back-compat)', async () => { + // Trello with friction list but NO cascade-friction label — current + // production cascade & ucho config. Pin labels:[] to guard against + // future regressions that would unexpectedly tag every card. + await materializeFrictionReport({ project: makeTrelloProject(), report: makeReport() }); + + expect(mockCreateWorkItem).toHaveBeenCalledWith(expect.objectContaining({ labels: [] })); + }); + it('falls back to provider.getWorkItemUrl when createWorkItem returns no URL', async () => { mockCreateWorkItem.mockResolvedValue({ id: 'friction-card-1', diff --git a/tests/unit/pm/config-friction-slot.test.ts b/tests/unit/pm/config-friction-slot.test.ts index 150a5a92..7fad08fa 100644 --- a/tests/unit/pm/config-friction-slot.test.ts +++ b/tests/unit/pm/config-friction-slot.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from 'vitest'; import { jiraConfigSchema } from '../../../src/integrations/pm/jira/config-schema.js'; import { linearConfigSchema } from '../../../src/integrations/pm/linear/config-schema.js'; import { trelloConfigSchema } from '../../../src/integrations/pm/trello/config-schema.js'; -import { getFrictionContainerId, getFrictionStatusDestination } from '../../../src/pm/config.js'; +import { + getFrictionContainerId, + getFrictionLabelId, + getFrictionStatusDestination, +} from '../../../src/pm/config.js'; import type { ProjectConfig } from '../../../src/types/index.js'; function makeTrelloProject(overrides: Record = {}): ProjectConfig { @@ -113,6 +117,80 @@ describe('getFrictionStatusDestination', () => { }); }); +describe('getFrictionLabelId', () => { + // 2026-05-10: opt-in label applied at materialize time. Mirrors the + // `getAlertLabelId` pattern from spec 019. Operators add the label key + // to the PM integration config to enable filtering/clustering of + // friction cards in the PM UI; absent config means cards file unlabeled. + it('returns Trello label ID from labels[cascade-friction] when configured', () => { + const project = makeTrelloProject({ + trello: { + boardId: 'board-1', + lists: { todo: 'list-todo', friction: 'list-friction' }, + labels: { 'cascade-friction': 'trello-label-friction-id' }, + }, + }); + expect(getFrictionLabelId(project)).toBe('trello-label-friction-id'); + }); + + it('returns JIRA label name from labels.cascadeFriction when configured', () => { + const project = makeJiraProject({ + jira: { + projectKey: 'PROJ', + baseUrl: 'https://acme.atlassian.net', + statuses: { todo: 'To Do', friction: 'Friction' }, + labels: { cascadeFriction: 'cascade-friction' }, + }, + }); + expect(getFrictionLabelId(project)).toBe('cascade-friction'); + }); + + it('returns Linear label UUID from labels.cascadeFriction when configured', () => { + const project = makeLinearProject({ + linear: { + teamId: 'team-1', + statuses: { todo: 'state-todo', friction: 'state-friction' }, + labels: { cascadeFriction: 'linear-label-uuid-friction' }, + }, + }); + expect(getFrictionLabelId(project)).toBe('linear-label-uuid-friction'); + }); + + it('returns undefined when the cascade-friction label is unconfigured (back-compat)', () => { + // Reflects current production cascade & ucho config. + expect(getFrictionLabelId(makeTrelloProject())).toBeUndefined(); + expect(getFrictionLabelId(makeJiraProject())).toBeUndefined(); + expect(getFrictionLabelId(makeLinearProject())).toBeUndefined(); + }); + + it('returns undefined for unknown PM provider types', () => { + expect( + getFrictionLabelId({ id: 'p1', pm: undefined } as unknown as ProjectConfig), + ).toBeUndefined(); + }); +}); + +describe('PM config schemas — friction label', () => { + it('jiraConfigSchema accepts labels.cascadeFriction', () => { + const result = jiraConfigSchema.safeParse({ + projectKey: 'P', + baseUrl: 'https://acme.atlassian.net', + statuses: { friction: 'Friction' }, + labels: { cascadeFriction: 'cascade-friction' }, + }); + expect(result.success).toBe(true); + }); + + it('linearConfigSchema accepts labels.cascadeFriction', () => { + const result = linearConfigSchema.safeParse({ + teamId: 'team-1', + statuses: { friction: 'state-uuid' }, + labels: { cascadeFriction: 'linear-label-uuid' }, + }); + expect(result.success).toBe(true); + }); +}); + describe('PM config schemas — friction slot', () => { it('trelloConfigSchema accepts lists.friction', () => { const result = trelloConfigSchema.safeParse({