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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ The dashboard's **What's New** rail reads this file at request time
entries. Keep entries terse — one line per item under each version
date, grouped by `Added` / `Changed` / `Fixed` / `Removed`.

## [2026-07-16] — v0.26.0 · Artifact Studio and trustworthy completion

### Added

- **Artifact Studio ships collaborative, shareable deliverables.** Teams can create and revise rich artifacts, manage revisions and previews, attach source assets, publish token-scoped read-only shares, and govern workspace defaults and API-key access.
- **Completion recommendations expose structured verification state.** Operators can inspect ready, blocked, verifying, unavailable, and stale evidence with per-fact status, observation and retry times, diagnostics, and source links.
- **Authorized operators can deliberately override incomplete verification.** A destructive **Mark done anyway** path lists unresolved reasons and records the structured assessment plus override decision in the issue audit trail.

### Changed

- **Completion evidence refreshes when its sources change.** GitHub webhooks, a deduplicated reconciliation job, scheduled sweeps, card visibility, window focus, and a manual action rebuild recommendations from current trusted evidence; realtime invalidation keeps mounted cards synchronized.
- **Automatic completion remains fail-closed.** Only a current `READY` assessment may complete automatically, while stale or unavailable evidence requires explicit operator judgment.

### Fixed

- **Webhook retry draining uses BullMQ-safe dedupe keys.** Stable per-attempt job IDs no longer contain BullMQ's reserved colon delimiter, restoring automatic queueing while durable delivery rows remain authoritative in Postgres.

## [2026-07-16] — v0.25.0 · Native Hermes conversations

### Added
Expand Down
18 changes: 18 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

> Append-only session log. Read at session start. Update at session end.

## 2026-07-16 — v0.26.0 coordinated release preparation

Release coordination confirmed that AXI-113 merged after v0.25.0 with no other
active release owner. A patch tag would therefore have mislabeled an unreleased
feature, so v0.26.0 is prepared from exact current `main` at
`3ebceb99035a23cd173c6c2bae39c2fd78116e9f`. While release review was running,
AXI-86 merged to `main` as `ae431384a24a6cccc2aa3d8bbfb5364f4098bdc3`;
serialization therefore rebases the release onto that exact newer tip and
includes Artifact Studio alongside AXI-113's structured completion evidence
and AXI-112's live-smoke delivery-drain hotfix.

Release review found that the What's New feed used only the bracketed calendar
date as its persisted identity, so two same-day releases could collide and the
newer release could incorrectly appear already seen. Changelog entries now
carry a release-unique SemVer identity, account preferences persist the exact
release viewed, legacy users receive one safe unseen notification, and parser
coverage locks in distinct v0.25.0/v0.26.0 behavior on the same date.

## 2026-07-16 — v0.25.0 live delivery-drain finding

The v0.25.0 production smoke found the maintenance delivery drain passing
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "forge",
"version": "0.25.0",
"version": "0.26.0",
"private": true,
"description": "Forge — a fast, minimalist, keyboard-driven project management platform with pluggable agents.",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "User" ADD COLUMN "changelogSeenRelease" TEXT;
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,9 @@ model User {
/// Drives the "unseen entries" dot — when the latest changelog date
/// is newer than this, the dot shows. Null = never seen.
changelogSeenAt DateTime?
/// Stable release identity last viewed by this user. Unlike the date above,
/// this distinguishes multiple releases shipped on the same calendar day.
changelogSeenRelease String?
/// Instance-wide role. Default MEMBER; the bootstrap operator (matching
/// `ADMIN_EMAIL`) is promoted to INSTANCE_ADMIN on sign-in and by the
/// backfill script. Gates the /admin shell and global AgentProfile
Expand Down
9 changes: 8 additions & 1 deletion src/app/(app)/w/[slug]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,13 @@ export default function DashboardPage() {
id: "whats-new",
title: "What's new",
defaultWidth: "half",
node: <WhatsNewTile slug={slug} seenAt={account?.changelogSeenAt ?? null} />,
node: (
<WhatsNewTile
slug={slug}
seenAt={account?.changelogSeenAt ?? null}
seenRelease={account?.changelogSeenRelease ?? null}
/>
),
},
...(!showPrimarySuggestions
? [
Expand Down Expand Up @@ -266,6 +272,7 @@ export default function DashboardPage() {
});
}, [
account?.changelogSeenAt,
account?.changelogSeenRelease,
flowBreakpoint,
showPrimarySuggestions,
slug,
Expand Down
11 changes: 5 additions & 6 deletions src/app/(app)/w/[slug]/whats-new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils";
*/
export default function WhatsNewPage() {
const { data, isLoading } = trpc.system.changelogFull.useQuery();
const latestReleaseId = data?.entries[0]?.id;

// Opening this page is the "I've seen the changes" signal — stamp it
// once on mount so the dashboard tile's unseen dot clears. Invalidate
Expand All @@ -25,9 +26,9 @@ export default function WhatsNewPage() {
onSuccess: () => utils.user.me.invalidate(),
});
useEffect(() => {
markSeen.mutate();
if (latestReleaseId) markSeen.mutate({ releaseId: latestReleaseId });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [latestReleaseId]);

return (
<>
Expand Down Expand Up @@ -57,11 +58,9 @@ export default function WhatsNewPage() {
</div>
) : (
data.entries.map((entry) => (
<section key={entry.version} className="space-y-3">
<section key={entry.id} className="space-y-3">
<header className="flex items-baseline gap-2 border-b border-border pb-1">
<h2 className="text-base font-semibold tracking-tight">
{entry.heading}
</h2>
<h2 className="text-base font-semibold tracking-tight">{entry.heading}</h2>
</header>
{entry.items.length === 0 ? (
<div className="text-meta italic text-muted-foreground">
Expand Down
15 changes: 11 additions & 4 deletions src/components/dashboard/whats-new-tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Link from "next/link";
import { Sparkles } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { hasUnseenChangelog } from "@/lib/changelog";

/**
* What's New tile — a compact list of the most recent CHANGELOG
Expand All @@ -18,17 +19,23 @@ import { cn } from "@/lib/utils";
export function WhatsNewTile({
slug,
seenAt = null,
seenRelease = null,
}: {
slug: string;
/** User's `changelogSeenAt`. When the latest dated entry is newer (or
* this is null), an "unseen" dot shows until they open /whats-new. */
seenAt?: string | Date | null;
seenRelease?: string | null;
}) {
const { data, isLoading } = trpc.system.changelog.useQuery({ limit: 5 });

// Newest dated entry vs. last-seen timestamp → "unseen" indicator.
const latestDate = data?.entries.find((e) => e.date)?.date ?? null;
const hasUnseen = !!latestDate && (!seenAt || new Date(latestDate) > new Date(seenAt));
const latest = data?.entries.find((entry) => entry.date) ?? null;
// Release identity is authoritative. A null value after this migration is
// intentionally unseen once, including for users who viewed an earlier
// release on the same day; the timestamp remains a legacy fallback only
// for old changelog entries without a release identity.
const hasUnseen = hasUnseenChangelog(latest, seenRelease, seenAt);

if (isLoading || !data) {
return (
Expand Down Expand Up @@ -84,9 +91,9 @@ export function WhatsNewTile({

{rest.length > 0 && (
<ul className="mt-3 flex flex-col gap-0.5 border-t border-border pt-2">
{rest.slice(0, 3).map((entry, i) => (
{rest.slice(0, 3).map((entry) => (
<li
key={`${entry.version}-${entry.heading}-${i}`}
key={entry.id}
className="text-meta truncate text-muted-foreground"
title={entry.heading}
data-whats-new-history
Expand Down
49 changes: 37 additions & 12 deletions src/components/global-shell/whats-new-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import { cn } from "@/lib/utils";
* Instance Admin. Marks the feed seen on mount (clears the unseen dot).
*/

const GROUPS: { type: "added" | "changed" | "fixed" | "removed"; label: string; icon: LucideIcon; cls: string }[] = [
const GROUPS: {
type: "added" | "changed" | "fixed" | "removed";
label: string;
icon: LucideIcon;
cls: string;
}[] = [
{ type: "added", label: "Added", icon: Plus, cls: "text-emerald-600" },
{ type: "changed", label: "Changed", icon: Pencil, cls: "text-ember" },
{ type: "fixed", label: "Fixed", icon: Wrench, cls: "text-blue-600" },
Expand All @@ -24,16 +29,22 @@ export function WhatsNewContent() {
const { data, isLoading } = trpc.system.changelogFull.useQuery();
const markSeen = trpc.user.markChangelogSeen.useMutation();
const utils = trpc.useUtils();
const latestReleaseId = data?.entries[0]?.id;

useEffect(() => {
markSeen.mutate(undefined, {
onSuccess: () => {
void utils.system.changelog.invalidate();
if (!latestReleaseId) return;
markSeen.mutate(
{ releaseId: latestReleaseId },
{
onSuccess: () => {
void utils.system.changelog.invalidate();
void utils.user.me.invalidate();
},
},
});
);
// once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [latestReleaseId]);

const entries = data?.entries ?? [];

Expand All @@ -44,21 +55,27 @@ export function WhatsNewContent() {
<Spinner />
</div>
) : entries.length === 0 ? (
<EmptyState title="No release notes yet" description="Shipped changes show up here, newest first." />
<EmptyState
title="No release notes yet"
description="Shipped changes show up here, newest first."
/>
) : (
<div className="flex flex-col gap-8">
{entries.map((entry, i) => {
// Heading format `## [YYYY-MM-DD] — vX.Y.Z · Title` → the release
// name (version + title) lives after the em-dash; the bracket date
// is the timestamp. Bare `## [YYYY-MM-DD]` falls back to the date.
const dash = entry.heading?.indexOf(" — ") ?? -1;
const title = dash >= 0 ? entry.heading.slice(dash + 3).trim() : entry.date ?? entry.version;
const title =
dash >= 0 ? entry.heading.slice(dash + 3).trim() : (entry.date ?? entry.version);
return (
<section key={`${entry.version}-${i}`} className="relative">
<section key={entry.id} className="relative">
<header className="mb-3 flex items-baseline gap-3 border-b border-border pb-2">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{entry.date && title !== entry.date && (
<span className="text-meta tabular-nums text-muted-foreground">{entry.date}</span>
<span className="text-meta tabular-nums text-muted-foreground">
{entry.date}
</span>
)}
{i === 0 && (
<span className="ml-auto rounded-full border border-ember/30 bg-ember/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-ember">
Expand All @@ -73,14 +90,22 @@ export function WhatsNewContent() {
const Ico = g.icon;
return (
<div key={g.type}>
<div className={cn("mb-1.5 inline-flex items-center gap-1.5 text-meta font-medium", g.cls)}>
<div
className={cn(
"text-meta mb-1.5 inline-flex items-center gap-1.5 font-medium",
g.cls,
)}
>
<Ico size={12} />
{g.label}
</div>
<ul className="flex flex-col gap-1.5 pl-1">
{items.map((it, j) => (
<li key={j} className="flex gap-2 text-sm leading-snug">
<span aria-hidden className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/50" />
<span
aria-hidden
className="mt-2 h-1 w-1 shrink-0 rounded-full bg-muted-foreground/50"
/>
<span className="text-foreground/90">{it.text}</span>
</li>
))}
Expand Down
9 changes: 9 additions & 0 deletions src/lib/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function hasUnseenChangelog(
latest: { id: string; release: string | null; date: string | null } | null,
seenRelease: string | null,
seenAt: string | Date | null,
): boolean {
if (!latest) return false;
if (latest.release) return latest.id !== seenRelease;
return !!latest.date && (!seenAt || new Date(latest.date) > new Date(seenAt));
}
Loading
Loading