feat: AI Highlights and engagement X draft on release

Claude-author Highlights plus an engagement-oriented tweet (≤280) during changelog distillation, with scheme-free links, Fusion version openers that drop .0 patch, and a soft deterministic fallback when Claude is offline.
This commit is contained in:
gsxdsm
2026-07-13 01:21:33 -07:00
parent b487f4542d
commit edfe57c8e0
6 changed files with 938 additions and 67 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Release notes open with AI Highlights, and the release script prints a ready-to-post engagement tweet.
category: feature
dev: distillReleaseNotes calls `claude -p --model sonnet` for Highlights + notes + ≤280-char X draft (engagement-oriented, varies per release); soft deterministic fallback if Claude is offline. release.mjs prints the draft after publish and on --dry-run.

View File

@@ -46,7 +46,7 @@ When changesets are merged to `main`, the `version.yml` workflow automatically o
- Consumes all pending changeset files
- Bumps package versions according to the changeset declarations
- Generates/updates `CHANGELOG.md` files for affected packages
- Distills the version's changeset summaries into grouped, end-user-facing release notes in the root `CHANGELOG.md`
- Distills the version's changeset summaries into grouped, end-user-facing release notes in the root `CHANGELOG.md` via Claude (`claude -p --model sonnet`): a **Highlights** section (top 3–5) plus category groups, and prints an engagement-oriented X draft (≤280 chars) after a local release
### 3. Merge the Version PR to release
@@ -125,9 +125,9 @@ This will trigger `release.yml` to build binaries and create a GitHub Release. N
|--------|-------------|
| `pnpm changeset` | Add a new changeset |
| `pnpm changeset status` | Check pending changesets |
| `pnpm release` | Local interactive release: previews changesets, lets you accept or override the proposed version, then bumps + builds + publishes + tags |
| `pnpm release` | Local interactive release: previews changesets, lets you accept or override the proposed version, then bumps + builds + publishes + tags; Claude authors Highlights + a ≤280-char engagement X draft (soft deterministic fallback if Claude is offline) |
| `pnpm release --yes` | Same, but auto-accepts the proposed version and skips the final confirmation |
| `pnpm release --dry-run` | Preview only — show changesets, proposed version, and prompt for override, then exit before any file/git/npm changes |
| `pnpm release --dry-run` | Preview only — show changesets, proposed version, and Claude-authored X draft preview, then exit before any file/git/npm changes |
| `pnpm release:version` | Apply changesets and bump versions (used by CI) |
| `pnpm --filter @runfusion/fusion build:exe` | Build binary for current platform |
| `pnpm --filter @runfusion/fusion build:exe -- --target <target>` | Cross-compile for a specific platform |

View File

@@ -3,8 +3,20 @@ import assert from "node:assert/strict";
import {
distillDeterministic,
distillReleaseNotes,
distillWithAi,
buildDistillationPrompt,
DISTILLATION_SYSTEM_PROMPT,
selectHighlights,
formatReleaseTweet,
formatTweetVersionLabel,
ensureFusionVersionPrefix,
buildChangelogUrl,
parseJsonFromLlm,
normalizeAiDistillResult,
fitTweetToBudget,
SHORT_RELEASE_URL,
HIGHLIGHTS_MAX,
} from "../lib/distill-release-notes.mjs";
// --- distillDeterministic ---
@@ -19,10 +31,13 @@ test("groups entries by category in display order", () => {
const { notes, source } = distillDeterministic(entries, "1.0.0");
assert.equal(source, "deterministic");
// Feature section comes first.
// Feature section comes first among category sections; Highlights lead the doc.
const highlightsIdx = notes.indexOf("### Highlights");
const featureIdx = notes.indexOf("### New");
const fixIdx = notes.indexOf("### Fixed");
const breakingIdx = notes.indexOf("### Breaking");
assert.ok(highlightsIdx > -1);
assert.ok(highlightsIdx < featureIdx);
assert.ok(featureIdx > -1);
assert.ok(featureIdx < fixIdx);
assert.ok(fixIdx < breakingIdx);
@@ -59,22 +74,25 @@ test("groups multiple entries in same category", () => {
});
test("handles empty entries array", () => {
const { notes, source } = distillDeterministic([], "1.0.0");
const { notes, source, highlights } = distillDeterministic([], "1.0.0");
assert.equal(source, "deterministic");
assert.match(notes, /No changes in v1\.0\.0/);
assert.deepEqual(highlights, []);
});
test("handles null/undefined entries", () => {
const { notes } = distillDeterministic(null, "1.0.0");
const { notes, highlights } = distillDeterministic(null, "1.0.0");
assert.match(notes, /No changes/);
assert.deepEqual(highlights, []);
});
test("single entry produces well-formed notes", () => {
test("single entry produces Highlights plus category section", () => {
const entries = [
{ summary: "Add cool feature.", category: "feature", legacy: false },
];
const { notes } = distillDeterministic(entries, "2.0.0");
assert.match(notes, /^### New\n\n- Add cool feature\.$/);
const { notes, highlights } = distillDeterministic(entries, "2.0.0");
assert.deepEqual(highlights, ["Add cool feature."]);
assert.match(notes, /^### Highlights\n\n- Add cool feature\.\n\n### New\n\n- Add cool feature\.$/);
});
test("includes internal category when entries exist", () => {
@@ -132,7 +150,7 @@ test("multiple categories render in correct order", () => {
{ summary: "Internal cleanup.", category: "internal", legacy: false },
];
const { notes } = distillDeterministic(entries, "1.0.0");
const order = ["### New", "### Fixed", "### Breaking", "### Security", "### Performance", "### Internal"]
const order = ["### Highlights", "### New", "### Fixed", "### Breaking", "### Security", "### Performance", "### Internal"]
.map((h) => notes.indexOf(h));
// Each should be found and in ascending order.
for (let i = 0; i < order.length - 1; i++) {
@@ -141,6 +159,129 @@ test("multiple categories render in correct order", () => {
}
});
test("returns highlights alongside notes", () => {
const entries = [
{ summary: "Breaking API.", category: "breaking", legacy: false },
{ summary: "New widget.", category: "feature", legacy: false },
{ summary: "Bug fix.", category: "fix", legacy: false },
];
const { notes, highlights } = distillDeterministic(entries, "1.0.0");
assert.equal(highlights.length, 3);
assert.equal(highlights[0], "Breaking API.");
assert.match(notes, /### Highlights/);
for (const h of highlights) {
assert.ok(notes.includes(`- ${h}`));
}
});
// --- selectHighlights ---
test("selectHighlights prefers breaking and security over features", () => {
const entries = [
{ summary: "Feature A.", category: "feature", legacy: false },
{ summary: "Security fix.", category: "security", legacy: false },
{ summary: "Breaking change.", category: "breaking", legacy: false },
{ summary: "Feature B.", category: "feature", legacy: false },
];
const highlights = selectHighlights(entries);
assert.equal(highlights[0], "Breaking change.");
assert.equal(highlights[1], "Security fix.");
assert.ok(highlights.includes("Feature A."));
});
test("selectHighlights caps at HIGHLIGHTS_MAX", () => {
const entries = Array.from({ length: 12 }, (_, i) => ({
summary: `Feature ${i}.`,
category: "feature",
legacy: false,
}));
const highlights = selectHighlights(entries);
assert.equal(highlights.length, HIGHLIGHTS_MAX);
});
test("selectHighlights returns fewer than min when release is small", () => {
const entries = [
{ summary: "Only one.", category: "feature", legacy: false },
{ summary: "Only two.", category: "fix", legacy: false },
];
const highlights = selectHighlights(entries);
assert.equal(highlights.length, 2);
});
test("selectHighlights prefers non-internal when enough user-facing exist", () => {
const entries = [
{ summary: "Internal A.", category: "internal", legacy: false },
{ summary: "Feature A.", category: "feature", legacy: false },
{ summary: "Feature B.", category: "feature", legacy: false },
{ summary: "Feature C.", category: "feature", legacy: false },
{ summary: "Internal B.", category: "internal", legacy: false },
];
const highlights = selectHighlights(entries);
assert.ok(highlights.every((h) => !h.startsWith("Internal")));
assert.equal(highlights.length, 3);
});
test("selectHighlights falls back to internal when no user-facing entries", () => {
const entries = [
{ summary: "Internal A.", category: "internal", legacy: false },
{ summary: "Internal B.", category: "internal", legacy: false },
];
const highlights = selectHighlights(entries);
assert.deepEqual(highlights, ["Internal A.", "Internal B."]);
});
// --- formatReleaseTweet ---
test("formatReleaseTweet stays within 280 characters", () => {
const highlights = [
"Add an interactive worktree-rooted Terminal tab to the task detail view.",
"Agents can now add files to a task's File Scope while working.",
"Agents save screenshots, videos, HTML mockups, and PDFs as artifacts.",
"Add a persistent Advanced settings toggle for uncommon Settings sections.",
"Guide repeatable Compound Engineering cycles from product grounding.",
];
const tweet = formatReleaseTweet({ version: "0.58.0", highlights });
assert.ok(tweet.length <= 280, `tweet length ${tweet.length} exceeds 280`);
assert.match(tweet, /^Fusion 0\.58:/);
assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/);
assert.doesNotMatch(tweet, /https?:\/\//);
});
test("formatReleaseTweet includes version and changelog link with no highlights", () => {
const tweet = formatReleaseTweet({ version: "1.2.3", highlights: [] });
assert.ok(tweet.length <= 280);
assert.match(tweet, /^Fusion 1\.2\.3:/);
assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/);
assert.doesNotMatch(tweet, /•/);
});
test("formatReleaseTweet truncates when a single highlight is very long", () => {
const long = "A".repeat(400);
const tweet = formatReleaseTweet({ version: "9.9.9", highlights: [long] });
assert.ok(tweet.length <= 280, `tweet length ${tweet.length}`);
assert.match(tweet, /^Fusion 9\.9\.9:/);
assert.match(tweet, /…/);
assert.match(tweet, /github\.com\/Runfusion\/Fusion\/blob\/main\/CHANGELOG\.md/);
});
test("formatReleaseTweet drops excess highlights to stay under budget", () => {
const highlights = Array.from({ length: 5 }, (_, i) =>
`This is a fairly long highlight number ${i} describing a user-facing change in detail.`,
);
const tweet = formatReleaseTweet({ version: "0.1.0", highlights });
assert.ok(tweet.length <= 280);
// At least one bullet should survive when space allows.
assert.match(tweet, /•/);
});
test("buildChangelogUrl is always main CHANGELOG without https", () => {
assert.equal(
buildChangelogUrl("0.58.0"),
"github.com/Runfusion/Fusion/blob/main/CHANGELOG.md",
);
assert.equal(buildChangelogUrl("9.9.9"), buildChangelogUrl("0.1.0"));
});
// --- buildDistillationPrompt ---
test("builds prompt with all entries", () => {
@@ -148,7 +289,10 @@ test("builds prompt with all entries", () => {
{ summary: "Add feature.", category: "feature", legacy: false, dev: "Uses tool X." },
{ summary: "Fix bug.", category: "fix", legacy: false },
];
const prompt = buildDistillationPrompt(entries);
const prompt = buildDistillationPrompt(entries, "1.0.0", "https://example.com/CHANGELOG.md");
assert.match(prompt, /Version: 1\.0\.0/);
assert.match(prompt, /Tweet opener \(required form\): Fusion 1\.0:/);
assert.match(prompt, /Changelog URL/);
assert.match(prompt, /\[1\]/);
assert.match(prompt, /\[2\]/);
assert.match(prompt, /category: feature/);
@@ -160,16 +304,181 @@ test("builds prompt without dev for entries lacking it", () => {
const entries = [
{ summary: "Fix bug.", category: "fix", legacy: false },
];
const prompt = buildDistillationPrompt(entries);
const prompt = buildDistillationPrompt(entries, "1.0.0", "https://example.com/CHANGELOG.md");
assert.doesNotMatch(prompt, /dev:/);
});
// --- DISTILLATION_SYSTEM_PROMPT ---
test("system prompt contains key instructions", () => {
assert.match(DISTILLATION_SYSTEM_PROMPT, /release notes/i);
assert.match(DISTILLATION_SYSTEM_PROMPT, /release-notes/i);
assert.match(DISTILLATION_SYSTEM_PROMPT, /operator/i);
assert.match(DISTILLATION_SYSTEM_PROMPT, /### Highlights/);
assert.match(DISTILLATION_SYSTEM_PROMPT, /### New/);
assert.match(DISTILLATION_SYSTEM_PROMPT, /### Fixed/);
assert.match(DISTILLATION_SYSTEM_PROMPT, /omit empty sections/i);
assert.match(DISTILLATION_SYSTEM_PROMPT, /engagement/i);
assert.match(DISTILLATION_SYSTEM_PROMPT, /280/);
});
// --- AI path (injected chatComplete; no live Claude required) ---
test("distillWithAi uses chatComplete and returns source ai", async () => {
const entries = [
{ summary: "Add Terminal tab on tasks.", category: "feature", legacy: false },
{ summary: "Agents can expand File Scope mid-run.", category: "feature", legacy: false },
{ summary: "Artifacts gallery for screenshots and videos.", category: "feature", legacy: false },
];
const aiPayload = {
highlights: [
"Task Terminal tab rooted in the worktree",
"Agents expand File Scope as they work",
"Artifacts gallery for media deliverables",
],
notes: [
"### Highlights",
"",
"- Task Terminal tab rooted in the worktree",
"- Agents expand File Scope as they work",
"- Artifacts gallery for media deliverables",
"",
"### New",
"",
"- Add Terminal tab on tasks.",
"- Agents can expand File Scope mid-run.",
"- Artifacts gallery for screenshots and videos.",
].join("\n"),
tweet:
"Fusion 0.58: Terminals in the worktree, File Scope that grows with the agent, and an Artifacts gallery that actually shows the work.\ngithub.com/Runfusion/Fusion/blob/main/CHANGELOG.md",
};
const result = await distillWithAi(entries, "0.58.0", {
chatComplete: async () => JSON.stringify(aiPayload),
});
assert.ok(result);
assert.equal(result.source, "ai");
assert.equal(result.highlights.length, 3);
assert.ok(result.tweet.length <= 280);
assert.match(result.notes, /### Highlights/);
assert.match(result.tweet, /Fusion 0\.58:/);
});
test("distillReleaseNotes falls back when AI is unavailable", async () => {
const entries = [
{ summary: "Add feature X.", category: "feature", legacy: false },
];
const result = await distillReleaseNotes(entries, "1.2.3", {
chatComplete: async () => null,
});
assert.equal(result.source, "deterministic");
assert.match(result.notes, /### Highlights/);
assert.ok(result.tweet.length <= 280);
});
test("parseJsonFromLlm strips fences", () => {
const parsed = parseJsonFromLlm('```json\n{"highlights":["A"],"notes":"### Highlights\\n\\n- A","tweet":"hi"}\n```');
assert.deepEqual(parsed.highlights, ["A"]);
});
test("normalizeAiDistillResult injects missing Highlights heading", () => {
const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
const normalized = normalizeAiDistillResult(
{
highlights: ["One thing", "Two thing", "Three thing"],
notes: "### New\n\n- One thing",
tweet: `Fusion v1.0.0 shipped three things.\n${changelogUrl}`,
},
"1.0.0",
changelogUrl,
);
assert.ok(normalized);
assert.match(normalized.notes, /^### Highlights/);
});
test("formatTweetVersionLabel drops leading v and .0 patch", () => {
assert.equal(formatTweetVersionLabel("0.58.0"), "Fusion 0.58");
assert.equal(formatTweetVersionLabel("v0.58.0"), "Fusion 0.58");
assert.equal(formatTweetVersionLabel("0.58.1"), "Fusion 0.58.1");
assert.equal(formatTweetVersionLabel("1.0.0"), "Fusion 1.0");
});
test("ensureFusionVersionPrefix rewrites bare vX openers", () => {
assert.equal(
ensureFusionVersionPrefix("v0.58.0: terminals in task detail", "0.58.0"),
"Fusion 0.58: terminals in task detail",
);
assert.equal(
ensureFusionVersionPrefix("Fusion v0.58.0 is out! terminals", "0.58.0"),
"Fusion 0.58: terminals",
);
assert.equal(
ensureFusionVersionPrefix("Fusion 0.58.1: patch release", "0.58.1"),
"Fusion 0.58.1: patch release",
);
});
test("fitTweetToBudget keeps GitHub changelog path when under 280", () => {
const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
const tweet = `Fusion 0.58: Terminals in task detail.\n${changelogUrl}`;
const fit = fitTweetToBudget(tweet, {
version: "0.58.0",
highlights: ["Terminals in task detail"],
changelogUrl,
});
assert.ok(fit.length <= 280);
assert.ok(fit.includes(changelogUrl));
assert.match(fit, /^Fusion 0\.58:/);
assert.doesNotMatch(fit, /https?:\/\//);
});
test("fitTweetToBudget strips https and version tags to static main path", () => {
const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
const tweet =
"Fusion v0.58.0: Terminals in task detail.\nhttps://github.com/Runfusion/Fusion/blob/v0.58.0/CHANGELOG.md";
const fit = fitTweetToBudget(tweet, {
version: "0.58.0",
highlights: ["Terminals in task detail"],
changelogUrl,
});
assert.ok(fit.includes(changelogUrl));
assert.doesNotMatch(fit, /https?:\/\//);
assert.doesNotMatch(fit, /blob\/v0\.58\.0\//);
});
test("fitTweetToBudget swaps to runfusion.ai when full changelog URL is too long", () => {
const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
// Long prose + full changelog path intentionally over 280.
const body =
"Fusion 0.58: your agents can now open a real terminal in the task itself, grab extra files into scope mid-run instead of stranding edits, and stash screenshots/videos/PDFs in a proper artifact gallery. Grok CLI joins as a bundled runtime plugin too.";
const tweet = `${body}\n${changelogUrl}`;
assert.ok(tweet.length > 280, "precondition: over budget");
const fit = fitTweetToBudget(tweet, {
version: "0.58.0",
highlights: ["Terminal", "File Scope", "Artifacts"],
changelogUrl,
});
assert.ok(fit.length <= 280, `fit length ${fit.length}`);
assert.match(fit, /^Fusion 0\.58:/);
assert.ok(fit.includes(SHORT_RELEASE_URL), "should use short product link");
assert.ok(!fit.includes(changelogUrl), "should drop full changelog URL");
assert.doesNotMatch(fit, /https?:\/\//);
});
test("normalizeAiDistillResult uses short URL when AI tweet is over budget", () => {
const changelogUrl = "github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
const longTweet =
"v0.58.0 ships a worktree Terminal tab, mid-task File Scope expansion so edits never strand at merge, a full artifact gallery for screenshots videos and PDFs, plus Grok CLI as a bundled plugin with thinking-level controls everywhere. " +
changelogUrl;
assert.ok(longTweet.length > 280);
const normalized = normalizeAiDistillResult(
{
highlights: ["Terminal tab", "File Scope expansion", "Artifact gallery"],
notes: "### Highlights\n\n- Terminal tab\n- File Scope expansion\n- Artifact gallery",
tweet: longTweet,
},
"0.58.0",
changelogUrl,
);
assert.ok(normalized);
assert.ok(normalized.tweet.length <= 280);
assert.ok(normalized.tweet.includes(SHORT_RELEASE_URL));
assert.doesNotMatch(normalized.tweet, /https?:\/\//);
});

View File

@@ -17,7 +17,7 @@ import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { parseChangesetBody } from "./lib/changeset-schema.mjs";
import { distillDeterministic } from "./lib/distill-release-notes.mjs";
import { distillReleaseNotes } from "./lib/distill-release-notes.mjs";
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
const args = process.argv.slice(2);
@@ -80,7 +80,12 @@ if (entries.length === 0) {
process.exit(0);
}
const { notes: distilledNotes, source } = distillDeterministic(entries, version);
/*
* FNXC:Changelog 2026-07-13-15:45:
* Prefer Claude CLI distillation (`claude -p --model sonnet`) when available
* in the environment; otherwise soft-fallback deterministic. Never blocks.
*/
const { notes: distilledNotes, source } = await distillReleaseNotes(entries, version);
const changelogContent = readFileSync(CHANGELOG_PATH, "utf8");
const updated = replaceVersionSection(changelogContent, version, distilledNotes);

View File

@@ -1,54 +1,113 @@
/*
* FNXC:Changelog 2026-06-24-15:30:
* Release-notes distillation module. Transforms parsed changeset entries
* into grouped, end-user-facing release notes. The deterministic fallback
* builds a category-grouped bullet list directly from the structured
* `summary` fields — no model call. When a model is available, the prompt
* and system prompt defined here can be used to produce curated, polished
* notes via `createFnAgent`.
* into grouped, end-user-facing release notes for Fusion operators.
*
* Audience is Fusion operators: behavior, fixes, what changed — minimal
* internals. The `dev` field is preserved in per-package CHANGELOGs but
* excluded from distilled release notes by default.
* FNXC:Changelog 2026-07-13-15:45:
* Highlights + X draft are AI-authored via the local Claude CLI
* (`claude -p --model sonnet`). Each release gets a fresh engagement-oriented
* tweet (not a fixed template). Deterministic ranking remains only as a soft
* fallback so offline/CI releases without Claude never block.
*/
import { spawnSync } from "node:child_process";
import { CATEGORIES, CATEGORY_HEADINGS } from "./changeset-schema.mjs";
/** Preferred min/max size of the Highlights section when enough entries exist. */
export const HIGHLIGHTS_MIN = 3;
export const HIGHLIGHTS_MAX = 5;
/** Wall-clock budget for the Claude CLI call. */
export const RELEASE_LLM_TIMEOUT_MS = 90_000;
/** Default Claude model alias for release distillation. */
export const DEFAULT_CLAUDE_MODEL = "sonnet";
/**
* System prompt for AI distillation via `createFnAgent`.
* Instructs the model to produce grouped markdown release notes for a
* Fusion operator audience, using only the `summary` fields as input.
* Lower number = higher highlight priority (deterministic fallback only).
*/
export const HIGHLIGHT_PRIORITY = {
breaking: 0,
security: 1,
feature: 2,
fix: 3,
performance: 4,
internal: 5,
};
/*
* FNXC:Changelog 2026-07-13-16:10:
* Tweet links omit the https:// scheme to save characters on X
* (github.com/.../CHANGELOG.md, or runfusion.ai when still too long).
*
* FNXC:Changelog 2026-07-13-16:25:
* The tweet changelog CTA is always the main-branch file path (not a version tag)
* so operators land on the live CHANGELOG.md: github.com/Runfusion/Fusion/blob/main/CHANGELOG.md
*/
/** Static scheme-free changelog path used in release tweets. */
export const STATIC_CHANGELOG_URL =
"github.com/Runfusion/Fusion/blob/main/CHANGELOG.md";
/** Short product link used in tweets when the full changelog URL is too long. */
export const SHORT_RELEASE_URL = "runfusion.ai";
/**
* System prompt for Claude distillation.
* Produces highlights, full notes, and an engagement-driving tweet.
*/
export const DISTILLATION_SYSTEM_PROMPT = [
"You are a release-notes writer for Fusion, a model-agnostic AI agent orchestration product.",
"Your audience is Fusion operators — developers using the product, not its internals.",
"Produce clean, grouped markdown release notes from the provided changeset entries.",
"Group under these headings (omit empty sections):",
" ### New (features)",
" ### Fixed (bug fixes)",
" ### Breaking (breaking changes)",
" ### Security (security fixes)",
" ### Performance (performance improvements)",
" ### Internal (internal-only changes)",
"Rules:",
"- Use the `summary` text verbatim or lightly edited for clarity and grouping.",
"- Do NOT include internal class names, file paths, or implementation detail.",
"- Do NOT include the `dev` field content unless it is user-relevant migration guidance.",
"- Write one bullet per entry, prefixed with `- `.",
"- Omit empty sections entirely.",
"- Do NOT add a title or version heading — only the grouped sections.",
"- Respond with markdown only, no preamble or explanation.",
"You are a release-notes writer and social copywriter for Fusion,",
"a model-agnostic AI agent orchestration product.",
"Audience: Fusion operators (developers who run Fusion), not internals.",
"",
"Return STRICT JSON only — no markdown fences, no preamble:",
'{ "highlights": string[3..5], "notes": string, "tweet": string }',
"",
"highlights:",
"- Top 3–5 user-facing changes (prefer breaking, security, features, then fixes).",
"- Punchy, benefit-led phrasing. Do not invent features not in the input.",
"- One short phrase/sentence per item; no markdown inside the strings.",
"",
"notes:",
"- Markdown body only (no version heading).",
"- Start with ### Highlights using the same 3–5 items as `- ` bullets.",
"- Then group under (omit empty): ### New, ### Fixed, ### Breaking, ### Security, ### Performance, ### Internal.",
"- One `- ` bullet per entry; lightly edit for clarity; no file paths or class names.",
"",
"tweet:",
"- Ready to post on X. Hard max 280 characters including spaces and the URL.",
"- Goal: drive engagement (curiosity, replies, clicks) — not a dry changelog dump.",
"- Vary tone per release (excited, wry, bold, founder-voice). Never reuse a fixed template.",
"- Open with Fusion + version + colon, no leading v — e.g. \"Fusion 0.58: …\" (drop .0 patch when patch is 0; keep 0.58.1 as-is). Never \"v0.58.0:\" alone.",
"- Include a link: prefer the static GitHub changelog path when it fits.",
"- Link form: no https:// scheme — always github.com/Runfusion/Fusion/blob/main/CHANGELOG.md (not a version tag).",
"- If that still exceeds 280 chars, use runfusion.ai instead.",
"- Weave 2–4 of the highlights into a scroll-stopping hook; questions, contrast, or a bold claim are fine.",
"- Plain text only. At most one hashtag. Emoji optional and sparse.",
"",
"JSON only.",
].join("\n");
/**
* Build the user-facing prompt for AI distillation.
* Lists each entry as `[N] category: X / summary: Y / dev: Z`.
*
* @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries
* @param {string} version
* @param {string} changelogUrl
* @returns {string}
*/
export function buildDistillationPrompt(entries) {
const lines = ["Produce release notes from these changeset entries:\n"];
export function buildDistillationPrompt(entries, version, changelogUrl) {
const opener = `${formatTweetVersionLabel(version)}:`;
const lines = [
`Version: ${version}`,
`Tweet opener (required form): ${opener}`,
`Changelog URL (prefer in the tweet when it fits ≤280): ${changelogUrl}`,
`Short link (use if the full changelog URL won't fit): ${SHORT_RELEASE_URL}`,
"",
"Write fresh, engagement-driving release copy from these changeset entries.",
`Start the tweet with "${opener}" (no leading v; omit trailing .0 patch when patch is 0).`,
"Do not use a stock \"X is out!\" opener every time — earn the click after that prefix.\n",
];
entries.forEach((entry, i) => {
const num = i + 1;
lines.push(`[${num}]`);
@@ -63,27 +122,144 @@ export function buildDistillationPrompt(entries) {
}
/**
* Deterministic fallback: build category-grouped release notes directly
* from the structured `summary` fields — no model call.
*
* Used when:
* - The model call fails, times out, or returns unparseable output
* - No model is configured (CI without model secret)
* - As a pre-model preview in dry-runs
* Rank and pick the top 3–5 highlight summaries (deterministic fallback only).
*
* @param {Array<{summary: string, category: string, legacy?: boolean}>} entries
* @param {string} version - Target version string (e.g. "0.47.0")
* @returns {{notes: string, source: "deterministic"}}
* @param {{min?: number, max?: number}} [opts]
* @returns {string[]}
*/
export function selectHighlights(entries, opts = {}) {
const min = opts.min ?? HIGHLIGHTS_MIN;
const max = opts.max ?? HIGHLIGHTS_MAX;
if (!entries || entries.length === 0) {
return [];
}
const ranked = entries
.map((entry, index) => ({
entry,
index,
priority: HIGHLIGHT_PRIORITY[entry.category] ?? HIGHLIGHT_PRIORITY.internal,
}))
.sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return a.index - b.index;
});
const nonInternal = ranked.filter((r) => r.entry.category !== "internal");
const pool = nonInternal.length >= Math.min(min, ranked.length) ? nonInternal : ranked;
const count = Math.min(max, pool.length);
return pool.slice(0, count).map((r) => r.entry.summary);
}
/**
* Static changelog path for release tweets (scheme-free, always main).
* @param {string} [_version] - unused; kept for call-site compatibility
* @returns {string}
*/
export function buildChangelogUrl(_version) {
return STATIC_CHANGELOG_URL;
}
/**
* Deterministic tweet formatter (soft fallback only).
*
* @param {{version: string, highlights: string[], changelogUrl?: string}} opts
* @returns {string}
*/
/**
* Canonical tweet version label: "Fusion 0.58" (drop patch when it is 0).
* e.g. 0.58.0 → "Fusion 0.58", 0.58.1 → "Fusion 0.58.1". No leading v.
* @param {string} version
* @returns {string}
*/
export function formatTweetVersionLabel(version) {
const bare = String(version || "").replace(/^v/i, "");
const m = bare.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
if (m) {
const [, major, minor, patch, rest] = m;
const display = Number(patch) === 0
? `${major}.${minor}${rest}`
: `${major}.${minor}.${patch}${rest}`;
return `Fusion ${display}`;
}
return `Fusion ${bare}`;
}
/**
* Bare version string for tweet matching (patch dropped when 0).
* @param {string} version
* @returns {string}
*/
export function formatTweetVersionBare(version) {
return formatTweetVersionLabel(version).replace(/^Fusion\s+/i, "");
}
export function formatReleaseTweet({ version, highlights = [], changelogUrl }) {
const url = changelogUrl || buildChangelogUrl(version);
const header = `${formatTweetVersionLabel(version)}:`;
const footer = url;
const fit = (items) => {
const body = items.length === 0
? ""
: `\n\n${items.map((h) => `• ${h}`).join("\n")}`;
return `${header}${body}\n\n${footer}`;
};
let items = [...highlights];
let tweet = fit(items);
while (tweet.length > 280 && items.length > 1) {
items = items.slice(0, -1);
tweet = fit(items);
}
if (tweet.length > 280 && items.length === 1) {
const prefix = `${header}\n\n• `;
const suffix = `\n\n${footer}`;
const maxSummary = 280 - prefix.length - suffix.length;
if (maxSummary >= 12) {
let summary = items[0];
if (summary.length > maxSummary) {
summary = `${summary.slice(0, maxSummary - 1).trimEnd()}…`;
}
tweet = `${prefix}${summary}${suffix}`;
}
}
if (tweet.length > 280) {
tweet = `${header}\n\n${footer}`;
}
if (tweet.length > 280) {
tweet = tweet.slice(0, 280);
}
return tweet;
}
/**
* Deterministic fallback when Claude is unavailable.
*
* @param {Array<{summary: string, category: string, legacy?: boolean}>} entries
* @param {string} version
* @returns {{notes: string, source: "deterministic", highlights: string[], tweet: string}}
*/
export function distillDeterministic(entries, version) {
const changelogUrl = buildChangelogUrl(version);
if (!entries || entries.length === 0) {
return {
notes: `No changes in v${version}.`,
source: "deterministic",
highlights: [],
tweet: formatReleaseTweet({ version, highlights: [], changelogUrl }),
};
}
// Group entries by category, preserving entry order within each group.
const highlights = selectHighlights(entries);
const groups = new Map();
for (const cat of CATEGORIES) {
groups.set(cat, []);
@@ -94,8 +270,13 @@ export function distillDeterministic(entries, version) {
groups.get(cat).push(entry.summary);
}
// Build sections in display order, omitting empty categories.
const sections = [];
if (highlights.length > 0) {
const bullets = highlights.map((s) => `- ${s}`).join("\n");
sections.push(`### Highlights\n\n${bullets}`);
}
for (const cat of CATEGORIES) {
const summaries = groups.get(cat);
if (summaries.length === 0) continue;
@@ -108,5 +289,340 @@ export function distillDeterministic(entries, version) {
return {
notes: sections.join("\n\n"),
source: "deterministic",
highlights,
tweet: formatReleaseTweet({ version, highlights, changelogUrl }),
};
}
/**
* @param {string} raw
* @returns {unknown | null}
*/
export function parseJsonFromLlm(raw) {
if (!raw || typeof raw !== "string") return null;
let text = raw.trim();
const fence = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
if (fence) text = fence[1].trim();
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
try {
return JSON.parse(text.slice(start, end + 1));
} catch {
return null;
}
}
/**
* @param {unknown} parsed
* @param {string} version
* @param {string} changelogUrl
* @returns {{highlights: string[], notes: string, tweet: string} | null}
*/
export function normalizeAiDistillResult(parsed, version, changelogUrl) {
if (!parsed || typeof parsed !== "object") return null;
const obj = /** @type {Record<string, unknown>} */ (parsed);
const highlightsRaw = Array.isArray(obj.highlights) ? obj.highlights : null;
if (!highlightsRaw || highlightsRaw.length === 0) return null;
const highlights = highlightsRaw
.filter((h) => typeof h === "string" && h.trim())
.map((h) => h.trim())
.slice(0, HIGHLIGHTS_MAX);
if (highlights.length === 0) return null;
const notes = typeof obj.notes === "string" ? obj.notes.trim() : "";
if (!notes) return null;
let notesOut = notes;
if (!/^###\s+Highlights\b/m.test(notesOut)) {
const bullets = highlights.map((s) => `- ${s}`).join("\n");
notesOut = `### Highlights\n\n${bullets}\n\n${notesOut}`;
}
let tweet = typeof obj.tweet === "string" ? obj.tweet.trim() : "";
if (!tweet) {
tweet = formatReleaseTweet({ version, highlights, changelogUrl });
}
tweet = fitTweetToBudget(tweet, {
version,
highlights,
changelogUrl,
});
return { highlights, notes: notesOut, tweet };
}
/**
* Ensure a tweet is ≤280 chars. Prefer the full changelog URL; if too long,
* swap to runfusion.ai (no scheme), then trim prose before the link if needed.
*
* @param {string} tweet
* @param {{version: string, highlights: string[], changelogUrl: string}} ctx
* @returns {string}
*/
export function fitTweetToBudget(tweet, { version, highlights, changelogUrl }) {
let out = (tweet || "").trim();
if (!out) {
return formatReleaseTweet({ version, highlights, changelogUrl });
}
// Normalize any https:// forms the model may still emit.
out = stripTweetLinkSchemes(out, changelogUrl);
// Prefer "Fusion 0.58.0:" over bare "v0.58.0:" openers.
out = ensureFusionVersionPrefix(out, version);
const hasFull = out.includes(changelogUrl);
const hasShort = out.includes(SHORT_RELEASE_URL);
// Prefer attaching the GitHub changelog path when missing and it still fits.
if (!hasFull && !hasShort && out.length + 1 + changelogUrl.length <= 280) {
out = `${out}\n${changelogUrl}`;
}
if (out.length <= 280) {
return out;
}
// Too long with the GitHub path — swap to the short product link.
if (out.includes(changelogUrl)) {
out = out.split(changelogUrl).join(SHORT_RELEASE_URL);
} else if (!out.includes(SHORT_RELEASE_URL)) {
out = out.replace(
/(?:https?:\/\/)?github\.com\/Runfusion\/Fusion\/[^\s]+/g,
SHORT_RELEASE_URL,
);
}
if (out.length <= 280) {
return out;
}
// Still over: keep the short URL, trim prose before it (never mid-link).
const shortIdx = out.indexOf(SHORT_RELEASE_URL);
if (shortIdx > 0) {
const bodyBudget = 280 - SHORT_RELEASE_URL.length - 1;
let body = out.slice(0, shortIdx).trimEnd();
if (body.length > bodyBudget) {
body = `${body.slice(0, Math.max(0, bodyBudget - 1)).trimEnd()}…`;
}
out = `${body}\n${SHORT_RELEASE_URL}`;
} else if (out.length + 1 + SHORT_RELEASE_URL.length <= 280) {
out = `${out}\n${SHORT_RELEASE_URL}`;
} else {
// Last resort: rebuild with short link so the CTA still fits.
out = formatReleaseTweet({
version,
highlights,
changelogUrl: SHORT_RELEASE_URL,
});
}
if (out.length > 280) {
out = formatReleaseTweet({
version,
highlights,
changelogUrl: SHORT_RELEASE_URL,
});
}
return out;
}
/**
* Drop https:// from known release links so tweets stay scheme-free.
* @param {string} text
* @param {string} changelogUrl - scheme-free github.com/... path
* @returns {string}
*/
export function stripTweetLinkSchemes(text, changelogUrl) {
let out = text;
out = out.replace(/https?:\/\/(?:www\.)?runfusion\.ai\/?/gi, SHORT_RELEASE_URL);
// Any GitHub CHANGELOG link (scheme or version tag) → static main path.
out = out.replace(
/(?:https?:\/\/)?github\.com\/Runfusion\/Fusion\/blob\/(?:main|v[\w.-]+)\/CHANGELOG\.md/g,
changelogUrl || STATIC_CHANGELOG_URL,
);
return out;
}
/**
* Ensure the tweet opens with "Fusion <version>:" (no leading v on the number).
* Rewrites common "vX.Y.Z:" / "Fusion vX.Y.Z" openers.
*
* @param {string} text
* @param {string} version
* @returns {string}
*/
export function ensureFusionVersionPrefix(text, version) {
const label = formatTweetVersionLabel(version);
const bareFull = String(version || "").replace(/^v/i, "");
const bareDisplay = formatTweetVersionBare(version);
// Match full semver or display form (with/without .0 patch).
const versionAlt = bareFull === bareDisplay
? escapeRegExp(bareFull)
: `(?:${escapeRegExp(bareFull)}|${escapeRegExp(bareDisplay)})`;
let out = (text || "").trim();
if (!out) return `${label}:`;
// "Fusion v0.58.0" / "Fusion 0.58 is out" → "Fusion 0.58:"
out = out.replace(
new RegExp(`^Fusion\\s+v?${versionAlt}(?:\\s+is\\s+out!?)?\\s*:?\\s*`, "i"),
`${label}: `,
);
// Bare "v0.58.0:" / "0.58:" opener → "Fusion 0.58:"
out = out.replace(
new RegExp(`^v?${versionAlt}\\s*:\\s*`, "i"),
`${label}: `,
);
if (!new RegExp(`^Fusion\\s+${escapeRegExp(bareDisplay)}\\s*:`, "i").test(out)) {
out = `${label}: ${out}`;
}
// Collapse accidental double spaces after the colon.
return out.replace(/^([^:]+:)\s+/, "$1 ").trim();
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Resolve Claude CLI binary + model from env.
* @param {Record<string, string | undefined>} [env]
* @returns {{claudeBin: string, model: string}}
*/
export function resolveClaudeDistillConfig(env = process.env) {
return {
claudeBin: env.FUSION_RELEASE_CLAUDE_BIN || "claude",
model: env.FUSION_RELEASE_CLAUDE_MODEL || DEFAULT_CLAUDE_MODEL,
};
}
/**
* Call local Claude CLI headless: `claude -p --model sonnet`.
* Tools disabled so this is a pure text completion.
*
* @param {{
* system: string,
* user: string,
* claudeBin?: string,
* model?: string,
* timeoutMs?: number,
* spawnImpl?: typeof spawnSync,
* }} opts
* @returns {string | null}
*/
export function chatViaClaudeCli(opts) {
const {
system,
user,
claudeBin = "claude",
model = DEFAULT_CLAUDE_MODEL,
timeoutMs = RELEASE_LLM_TIMEOUT_MS,
spawnImpl = spawnSync,
} = opts;
// Combined prompt: Claude --system-prompt can be long; keep it simple and reliable.
const prompt = `${system}\n\n---\n\n${user}`;
const args = [
"-p",
prompt,
"--model",
model,
"--output-format",
"text",
// No tools: pure generation for release copy.
"--tools",
"",
"--permission-mode",
"dontAsk",
];
const r = spawnImpl(claudeBin, args, {
encoding: "utf8",
timeout: timeoutMs,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 4 * 1024 * 1024,
});
if (r.error || r.status !== 0) {
return null;
}
const out = (r.stdout || "").trim();
return out || null;
}
/**
* Attempt Claude AI distillation. Returns null when Claude is unavailable or output is invalid.
*
* @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries
* @param {string} version
* @param {{
* changelogUrl?: string,
* env?: Record<string, string | undefined>,
* chatComplete?: (args: {system: string, user: string}) => Promise<string | null> | string | null,
* allowClaudeCli?: boolean,
* timeoutMs?: number,
* spawnImpl?: typeof spawnSync,
* }} [options]
* @returns {Promise<{notes: string, source: "ai", highlights: string[], tweet: string} | null>}
*/
export async function distillWithAi(entries, version, options = {}) {
if (!entries || entries.length === 0) return null;
const changelogUrl = options.changelogUrl || buildChangelogUrl(version);
const system = DISTILLATION_SYSTEM_PROMPT;
const user = buildDistillationPrompt(entries, version, changelogUrl);
let raw = null;
if (typeof options.chatComplete === "function") {
raw = await options.chatComplete({ system, user });
} else if (options.allowClaudeCli !== false) {
const cfg = resolveClaudeDistillConfig(options.env || process.env);
raw = chatViaClaudeCli({
system,
user,
claudeBin: cfg.claudeBin,
model: cfg.model,
timeoutMs: options.timeoutMs ?? RELEASE_LLM_TIMEOUT_MS,
spawnImpl: options.spawnImpl,
});
}
if (!raw) return null;
const parsed = parseJsonFromLlm(raw);
const normalized = normalizeAiDistillResult(parsed, version, changelogUrl);
if (!normalized) return null;
return {
notes: normalized.notes,
highlights: normalized.highlights,
tweet: normalized.tweet,
source: "ai",
};
}
/**
* Distill release notes: Claude AI first, deterministic soft fallback.
*
* @param {Array<{summary: string, category: string, dev?: string, legacy?: boolean}>} entries
* @param {string} version
* @param {Parameters<typeof distillWithAi>[2]} [options]
* @returns {Promise<{notes: string, source: "ai" | "deterministic", highlights: string[], tweet: string}>}
*/
export async function distillReleaseNotes(entries, version, options = {}) {
try {
const ai = await distillWithAi(entries, version, options);
if (ai) return ai;
} catch {
// Soft fallback — release must never fail on distillation.
}
return distillDeterministic(entries, version);
}

View File

@@ -35,7 +35,7 @@ import {
} from "./lib/release-authorization-gate.mjs";
import { extractVersionNotes, replaceVersionSection } from "./lib/extract-version-notes.mjs";
import { parseChangesetFile } from "./lib/changeset-schema.mjs";
import { distillDeterministic } from "./lib/distill-release-notes.mjs";
import { distillReleaseNotes } from "./lib/distill-release-notes.mjs";
import { shouldPromptForVersion } from "./lib/release-prompt-gate.mjs";
import {
archivePointerLine,
@@ -577,6 +577,23 @@ if (chosenVersion !== proposedVersion) {
if (DRY_RUN) {
warn("--dry-run: stopping before version bump. No files modified, no commit, no publish, no tag.");
info(`Would release v${chosenVersion} (${releases.length} package(s) bumped).`);
/*
* FNXC:ReleaseScript 2026-07-13-15:25:
* Dry-run previews the LLM-authored Highlights + X draft (falls back to
* deterministic if no model is reachable) so operators can review the post
* without authorizing a real publish.
*/
const dryEntries = changesetSummaries.map(({ file }) => {
const raw = readFileSync(join(".changeset", file), "utf8");
return parseChangesetFile(raw).parsed;
}).filter(Boolean);
info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…");
const dryDistilled = await distillReleaseNotes(dryEntries, chosenVersion);
console.log("");
console.log(color(36, "─── Draft post for X (preview) ───"));
console.log(dryDistilled.tweet);
console.log(color(90, `(${dryDistilled.tweet.length}/280 chars; source: ${dryDistilled.source})`));
console.log(color(36, "──────────────────────────────────"));
process.exit(0);
}
@@ -648,18 +665,23 @@ syncRootChangelog();
ok("Root CHANGELOG.md updated.");
/*
* FNXC:Changelog 2026-06-24-16:30:
* Distill end-user-facing release notes from the captured changeset entries
* and replace the raw per-package aggregate in the root CHANGELOG for this
* version. Historical version sections are preserved untouched.
* FNXC:ReleaseScript 2026-07-13-15:45:
* Claude CLI (`claude -p --model sonnet`) authors Highlights (top 3–5), full
* notes, and an engagement-oriented X draft ≤280 chars. Soft deterministic
* fallback only if Claude is unreachable so release never blocks.
*/
info("Distilling release notes…");
const { notes: distilledNotes, source: distillSource } = distillDeterministic(capturedEntries, version);
info("Distilling release notes with Claude (sonnet; soft fallback if unavailable)…");
const {
notes: distilledNotes,
source: distillSource,
highlights: releaseHighlights,
tweet: releaseTweet,
} = await distillReleaseNotes(capturedEntries, version);
const changelogBeforeDistill = readFileSync("CHANGELOG.md", "utf8");
const changelogAfterDistill = replaceVersionSection(changelogBeforeDistill, version, distilledNotes);
if (changelogAfterDistill !== changelogBeforeDistill) {
writeFileSync("CHANGELOG.md", changelogAfterDistill);
ok(`Root CHANGELOG.md updated with distilled notes (source: ${distillSource}).`);
ok(`Root CHANGELOG.md updated with distilled notes (source: ${distillSource}; ${releaseHighlights.length} highlight(s)).`);
} else {
warn(`Could not locate version section in CHANGELOG.md for distillation; leaving raw aggregate.`);
}
@@ -753,3 +775,15 @@ if (githubReleaseStatus === "created") {
} else {
ok(`Released v${version}. Published to npm, tag pushed. GitHub Release was not created (see warnings above).`);
}
/*
* FNXC:ReleaseScript 2026-07-13-15:25:
* After a successful publish/tag, print the LLM-authored X draft (≤280 chars)
* produced during distillation so the operator can copy-paste to X.
*/
console.log("");
console.log(color(36, "─── Draft post for X (copy-paste) ───"));
console.log(releaseTweet);
console.log(color(90, `(${releaseTweet.length}/280 chars; source: ${distillSource})`));
console.log(color(36, "─────────────────────────────────────"));