Skip to content

Commit 6e59be6

Browse files
pugstrocursoragent
andcommitted
feat(forge): complete Slack bridge — schema previews, thumbnails, draft migration
Move prose draft mappers to workflow-mappers, add schema field previews and page-1 confirm thumbnails across workflows, metrics routing, template upgrade on held drafts, and Slack markup stripping in gather paths. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 9a83fcd commit 6e59be6

22 files changed

Lines changed: 933 additions & 693 deletions

‎src/slack/confirm/board.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { KnownBlock } from "@slack/web-api";
22
import type { BoardQuality } from "../gather/board.js";
3-
import { formatEnginePreviewLine } from "./engine-summary.js";
3+
import { formatEnginePreviewLine, formatSchemaFieldPreview } from "./engine-summary.js";
44

55
function kpiPreview(metricNames: string[], max = 4): string {
66
const lines = metricNames.slice(0, max).map((name) => `• ${name}`);
@@ -17,8 +17,9 @@ export function buildBoardConfirmBlocks(input: {
1717
filename: string;
1818
}): KnownBlock[] {
1919
const rowCount = Math.max(0, input.quality.lineCount - 1);
20+
const fields = formatSchemaFieldPreview(input.draftData);
2021

21-
return [
22+
const blocks: KnownBlock[] = [
2223
{
2324
type: "section",
2425
text: {
@@ -32,6 +33,13 @@ export function buildBoardConfirmBlocks(input: {
3233
})}_`,
3334
},
3435
},
36+
];
37+
38+
if (fields) {
39+
blocks.push({ type: "section", text: { type: "mrkdwn", text: fields } });
40+
}
41+
42+
blocks.push(
3543
{
3644
type: "section",
3745
text: { type: "mrkdwn", text: kpiPreview(input.quality.metricNames) },
@@ -54,5 +62,7 @@ export function buildBoardConfirmBlocks(input: {
5462
},
5563
],
5664
},
57-
];
65+
);
66+
67+
return blocks;
5868
}

‎src/slack/confirm/engine-summary.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,77 @@
11
import type { ForgeBuildReceipt } from "../../forge/receipt.js";
22
import { sourceCoverageScore } from "../../lint/grounding.js";
33

4+
const NARRATIVE_KEYS = [
5+
"summary",
6+
"executive_summary",
7+
"abstract",
8+
"decision",
9+
"context",
10+
"commentary",
11+
"objective",
12+
"root_cause",
13+
"background",
14+
] as const;
15+
16+
function truncateField(text: string, max = 120): string {
17+
const t = text.replace(/\s+/g, " ").trim();
18+
if (t.length <= max) return t;
19+
return `${t.slice(0, max - 1)}…`;
20+
}
21+
22+
function labelForKey(key: string): string {
23+
return key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
24+
}
25+
26+
/** Top schema fields for confirm cards — not a wall, max 3 lines. */
27+
export function formatSchemaFieldPreview(
28+
draftData: Record<string, unknown>,
29+
max = 3,
30+
): string {
31+
const lines: string[] = [];
32+
33+
for (const key of NARRATIVE_KEYS) {
34+
if (lines.length >= max) break;
35+
const value = draftData[key];
36+
if (typeof value !== "string" || value.trim().length < 8) continue;
37+
lines.push(`*${labelForKey(key)}:* ${truncateField(value)}`);
38+
}
39+
40+
if (lines.length < max) {
41+
const kpis = draftData.kpis as Array<{ name?: string; value?: string }> | undefined;
42+
if (kpis?.length) {
43+
const kpiLine = kpis
44+
.slice(0, 3)
45+
.map((k) => `${k.name ?? "KPI"} ${k.value ?? "—"}`)
46+
.join(" · ");
47+
lines.push(`*KPIs:* ${truncateField(kpiLine, 140)}`);
48+
}
49+
}
50+
51+
if (lines.length < max) {
52+
const scope = draftData.scope as Array<{ item?: string }> | undefined;
53+
if (scope?.length) {
54+
lines.push(
55+
`*Scope:* ${truncateField(scope.slice(0, 2).map((s) => s.item ?? "").filter(Boolean).join("; "))}`,
56+
);
57+
}
58+
}
59+
60+
if (lines.length < max) {
61+
const ws = draftData.workstreams as Array<{ name?: string; rag?: string }> | undefined;
62+
if (ws?.length) {
63+
lines.push(
64+
`*Workstreams:* ${ws
65+
.slice(0, 3)
66+
.map((w) => `${w.name ?? "—"} (${w.rag ?? "amber"})`)
67+
.join(" · ")}`,
68+
);
69+
}
70+
}
71+
72+
return lines.slice(0, max).join("\n");
73+
}
74+
475
/** One-line engine proof for confirm cards (pre-compile). */
576
export function formatEnginePreviewLine(input: {
677
templateId: string;

‎src/slack/confirm/freehand.ts‎

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { KnownBlock } from "@slack/web-api";
22
import type { DraftInference } from "../workflows/draft-inference.js";
3-
import { formatEnginePreviewLine } from "./engine-summary.js";
3+
import { formatEnginePreviewLine, formatSchemaFieldPreview } from "./engine-summary.js";
44

55
export function buildDraftChoiceBlocks(input: {
66
choiceId: string;
@@ -35,8 +35,9 @@ export function buildDraftConfirmBlocks(input: {
3535
}): KnownBlock[] {
3636
const { inference } = input;
3737
const title = typeof inference.draftData.title === "string" ? inference.draftData.title : "Draft";
38+
const fields = formatSchemaFieldPreview(inference.draftData);
3839

39-
return [
40+
const blocks: KnownBlock[] = [
4041
{
4142
type: "section",
4243
text: {
@@ -50,23 +51,39 @@ export function buildDraftConfirmBlocks(input: {
5051
})}_`,
5152
},
5253
},
53-
{
54-
type: "actions",
55-
elements: [
56-
{
57-
type: "button",
58-
action_id: "draft_confirm",
59-
text: { type: "plain_text", text: "Generate PDF" },
60-
style: "primary",
61-
value: input.pendingId,
62-
},
63-
{
64-
type: "button",
65-
action_id: "draft_cancel",
66-
text: { type: "plain_text", text: "Cancel" },
67-
value: input.pendingId,
68-
},
69-
],
70-
},
7154
];
55+
56+
if (fields) {
57+
blocks.push({
58+
type: "section",
59+
text: { type: "mrkdwn", text: fields },
60+
});
61+
}
62+
63+
blocks.push({
64+
type: "actions",
65+
elements: [
66+
{
67+
type: "button",
68+
action_id: "draft_confirm",
69+
text: { type: "plain_text", text: "Generate PDF" },
70+
style: "primary",
71+
value: input.pendingId,
72+
},
73+
{
74+
type: "button",
75+
action_id: "draft_retemplate",
76+
text: { type: "plain_text", text: "Change template" },
77+
value: input.pendingId,
78+
},
79+
{
80+
type: "button",
81+
action_id: "draft_cancel",
82+
text: { type: "plain_text", text: "Cancel" },
83+
value: input.pendingId,
84+
},
85+
],
86+
});
87+
88+
return blocks;
7289
}

‎src/slack/confirm/incident.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { KnownBlock } from "@slack/web-api";
22
import type { TranscriptQuality } from "../gather/context.js";
33
import type { ResolvedIncidentSource } from "../session.js";
44
import { isRootCauseConfirmed } from "../gather/transcript.js";
5+
import { formatEnginePreviewLine, formatSchemaFieldPreview } from "./engine-summary.js";
56

67
type TimelineEntry = { time: string; event: string };
78

@@ -67,19 +68,33 @@ export function buildIncidentConfirmBlocks(input: {
6768
title: string;
6869
filename: string;
6970
draftData: Record<string, unknown>;
71+
templateId?: string;
7072
}): KnownBlock[] {
7173
const preview = timelinePreview(input.draftData);
74+
const fields = formatSchemaFieldPreview(input.draftData);
75+
const templateId = input.templateId ?? "incident_report";
7276

73-
return [
77+
const blocks: KnownBlock[] = [
7478
{
7579
type: "section",
7680
text: {
7781
type: "mrkdwn",
7882
text:
79-
`*Incident report*\n` +
80-
`${formatSourceLink(input.source)} · ${severityLabel(input.draftData.severity)} · \`${input.filename}\``,
83+
`*${templateId === "postmortem" ? "Postmortem" : "Incident report"}*\n` +
84+
`${formatSourceLink(input.source)} · ${severityLabel(input.draftData.severity)} · \`${input.filename}\`\n` +
85+
`_${formatEnginePreviewLine({
86+
templateId,
87+
draftData: input.draftData,
88+
})}_`,
8189
},
8290
},
91+
];
92+
93+
if (fields) {
94+
blocks.push({ type: "section", text: { type: "mrkdwn", text: fields } });
95+
}
96+
97+
blocks.push(
8398
{
8499
type: "section",
85100
text: {
@@ -112,5 +127,7 @@ export function buildIncidentConfirmBlocks(input: {
112127
},
113128
],
114129
},
115-
];
130+
);
131+
132+
return blocks;
116133
}

‎src/slack/confirm/preview.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { producePreview } from "../../forge/pipeline.js";
2+
import { docforgeDestroyDocument } from "../../service.js";
3+
import { uploadPreviewToThread } from "../deliver/upload-preview.js";
4+
import type { ForgeMessageContext } from "../types.js";
5+
6+
/**
7+
* Render page-1 PNG and upload to the confirm thread — one thumbnail, not a
8+
* telemetry wall. Skipped when FORGE_CONFIRM_PREVIEW=off (tests/CI).
9+
*/
10+
export async function postConfirmPreview(
11+
ctx: Pick<ForgeMessageContext, "client" | "logger">,
12+
input: {
13+
templateId: string;
14+
draftData: Record<string, unknown>;
15+
filename: string;
16+
replyChannelId: string;
17+
threadTs: string;
18+
},
19+
): Promise<void> {
20+
if (process.env.FORGE_CONFIRM_PREVIEW === "off") return;
21+
22+
let documentId: string | undefined;
23+
try {
24+
const preview = await producePreview(input.templateId, input.draftData);
25+
documentId = preview.documentId;
26+
const base = input.filename.replace(/\.pdf$/i, "");
27+
await uploadPreviewToThread({
28+
client: ctx.client,
29+
channelId: input.replyChannelId,
30+
threadTs: input.threadTs,
31+
previewPath: preview.previewPath,
32+
filename: `${base}-preview.png`,
33+
initialComment: "Page 1 preview — generate PDF when ready.",
34+
});
35+
} catch (err) {
36+
ctx.logger.debug?.("confirm preview skipped", err);
37+
} finally {
38+
if (documentId) {
39+
try {
40+
await docforgeDestroyDocument(documentId);
41+
} catch (err) {
42+
ctx.logger.debug?.("confirm preview cleanup skipped", err);
43+
}
44+
}
45+
}
46+
}

‎src/slack/confirm/proposal.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { KnownBlock } from "@slack/web-api";
22
import type { TranscriptQuality } from "../gather/context.js";
33
import { pricingSubtotal, type PricingRow } from "../gather/pricing.js";
44
import type { ProposalEvidenceSnippet, ProposalSource } from "../gather/proposal-context.js";
5-
import { formatEnginePreviewLine } from "./engine-summary.js";
5+
import { formatEnginePreviewLine, formatSchemaFieldPreview } from "./engine-summary.js";
66

77
function formatMoney(total: number): string {
88
return `$${total.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
@@ -32,8 +32,9 @@ export function buildProposalConfirmBlocks(input: {
3232
const client = (input.draftData.client as { name?: string })?.name ?? input.source.clientName;
3333
const subtotal = pricingSubtotal(input.pricingRows);
3434
const totalLabel = formatMoney(Math.round(subtotal));
35+
const fields = formatSchemaFieldPreview(input.draftData);
3536

36-
return [
37+
const blocks: KnownBlock[] = [
3738
{
3839
type: "section",
3940
text: {
@@ -48,6 +49,13 @@ export function buildProposalConfirmBlocks(input: {
4849
})}_`,
4950
},
5051
},
52+
];
53+
54+
if (fields) {
55+
blocks.push({ type: "section", text: { type: "mrkdwn", text: fields } });
56+
}
57+
58+
blocks.push(
5159
{
5260
type: "section",
5361
text: { type: "mrkdwn", text: scopePreview(input.draftData) },
@@ -70,5 +78,7 @@ export function buildProposalConfirmBlocks(input: {
7078
},
7179
],
7280
},
73-
];
81+
);
82+
83+
return blocks;
7484
}

‎src/slack/confirm/status.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { KnownBlock } from "@slack/web-api";
2-
import { formatEnginePreviewLine } from "./engine-summary.js";
2+
import { formatEnginePreviewLine, formatSchemaFieldPreview } from "./engine-summary.js";
33

44
const RAG_EMOJI: Record<string, string> = { red: "🔴", amber: "🟠", green: "🟢" };
55

@@ -25,6 +25,7 @@ export function buildStatusConfirmBlocks(input: {
2525
filename: string;
2626
}): KnownBlock[] {
2727
const blockers = (input.draftData.blockers as string[] | undefined) ?? [];
28+
const fields = formatSchemaFieldPreview(input.draftData);
2829

2930
const blocks: KnownBlock[] = [
3031
{
@@ -40,12 +41,17 @@ export function buildStatusConfirmBlocks(input: {
4041
})}_`,
4142
},
4243
},
43-
{
44-
type: "section",
45-
text: { type: "mrkdwn", text: workstreamPreview(input.draftData) },
46-
},
4744
];
4845

46+
if (fields) {
47+
blocks.push({ type: "section", text: { type: "mrkdwn", text: fields } });
48+
}
49+
50+
blocks.push({
51+
type: "section",
52+
text: { type: "mrkdwn", text: workstreamPreview(input.draftData) },
53+
});
54+
4955
if (blockers.length) {
5056
blocks.push({
5157
type: "section",

0 commit comments

Comments
 (0)