Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 71 additions & 46 deletions src/friction/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FrictionReport['context']['pr']>): 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 <runId>` 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<FrictionReport['context']['pr']>): 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 · <category> · <severity>] <summary>`. 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 <iso>_` 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'),
};
}
15 changes: 13 additions & 2 deletions src/friction/materialize.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/pm/jira/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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(),
})
Expand Down
2 changes: 2 additions & 0 deletions src/integrations/pm/linear/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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(),

Expand Down
29 changes: 29 additions & 0 deletions src/pm/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
}

Expand Down Expand Up @@ -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 };
}
Expand Down Expand Up @@ -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`.
Expand Down
106 changes: 85 additions & 21 deletions tests/unit/friction/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,53 +33,117 @@ function makeReport(overrides: Partial<FrictionReport> = {}): FrictionReport {
title: 'feat: example',
url: 'https://github.com/acme/cascade/pull/12',
branch: 'feature/example',
headSha: 'abc123',
headSha: 'abc123def4567890',
},
},
...overrides,
};
}

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'),
Expand Down
Loading
Loading