diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index 629eb06..9909cdd 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -13,6 +13,7 @@
{"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0}
+{"_type":"issue","id":"islandflow-8fn","title":"implement alpaca-backed news wire view","description":"Why this issue exists and what needs to be done:\\nAdd an Alpaca-powered live news pipeline, API, storage, and web experience, including a dedicated /news route, Home preview, live fanout, history pagination, ticker resolution, and replay-mode live-only empty states.\\n\\nAcceptance criteria:\\n- normalized NewsStory contract and live channel exist\\n- ingest-news service backfills and streams Alpaca news\\n- API persists, serves, and fans out news\\n- web app exposes /news plus Home preview and drawer\\n- tests cover types, storage, API, and key UI behaviors\\n- turn documentation is added\\n\\nDesign:\\nReuse Islandflow drawer, chips, panes, and terminal styling; keep news live-only in v1 replay mode.\\n\\nNotes:\\nImplement client-side ticker filtering in v1 and expose latest revision only per provider+story_id.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T20:37:13Z","created_by":"dirtydishes","updated_at":"2026-05-18T20:55:11Z","started_at":"2026-05-18T20:37:20Z","closed_at":"2026-05-18T20:55:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-k8i","title":"Fix duplicate alert context import in API entrypoint","description":"Recent alert-context work introduced a duplicate fetchAlertContextByTraceId import in services/api/src/index.ts, which risks breaking TypeScript compilation and API startup. Remove the duplicate import and validate the affected API/web tests.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T13:01:58Z","created_by":"dirtydishes","updated_at":"2026-05-18T13:03:40Z","started_at":"2026-05-18T13:02:02Z","closed_at":"2026-05-18T13:03:40Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-lk9","title":"Fix PR creation workflow after Forgejo migration","description":"## Why\\nCreating pull requests with fails after the repository moved primary collaboration from GitHub to Forgejo. The current workflow still assumes GitHub GraphQL PR creation semantics, which do not work against the Forgejo remote.\\n\\n## What\\nInvestigate the current PR creation path, identify remaining GitHub-specific assumptions, and update the repo workflow/scripts/docs so contributors can reliably publish branches and open PRs in the Forgejo-based setup.\\n\\n## Acceptance Criteria\\n- The repo no longer instructs contributors to use a broken GitHub-specific PR creation path for Forgejo branches\\n- There is a documented and preferably scripted way to create the equivalent review request against Forgejo\\n- Validation demonstrates the new workflow behaves correctly or clearly documents any remaining platform limitation","status":"in_progress","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T10:26:47Z","created_by":"dirtydishes","updated_at":"2026-05-18T10:26:53Z","started_at":"2026-05-18T10:26:53Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"islandflow-1ei","title":"Make deploy helper remote-aware for Forgejo","description":"Why: scripts/deploy.ts hardcodes git remote name origin for fetch/pull/push and branch verification, but this repository now uses forgejo/github remotes and may not have an origin remote. What: update deploy.ts to resolve the deploy git remote robustly (Forgejo-aware), use it across local prechecks, branch publish, and remote rollout git operations, and keep behavior explicit in output.","status":"closed","priority":2,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-05-18T03:20:12Z","created_by":"dirtydishes","updated_at":"2026-05-18T03:22:39Z","started_at":"2026-05-18T03:20:16Z","closed_at":"2026-05-18T03:22:39Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
diff --git a/AGENTS.md b/AGENTS.md
index 3ab1cf0..8f1971b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -117,8 +117,9 @@ Each turn document must include these sections:
2. **Changes Made**
3. **Context**
4. **Important Implementation Details**
-5. **Expected Impact for End-Users**
-5. **Validation**
+5. **Relevant Diff Snippets**
+6. **Expected Impact for End-Users**
+7. **Validation**
6. **Issues, Limitations, and Mitigations**
7. **Follow-up Work**
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 64b6f16..cf6746b 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -708,7 +708,12 @@ h3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
+.page-grid-news {
+ grid-template-columns: minmax(0, 1fr);
+}
+
.page-grid-home > :nth-child(3),
+.page-grid-home > :nth-child(4),
.page-grid-tape > :nth-child(1),
.page-grid-replay > :nth-child(1) {
grid-column: 1 / -1;
@@ -933,6 +938,7 @@ h3 {
}
.page-grid-home > :nth-child(3),
+.page-grid-home > :nth-child(4),
.page-grid-replay > :not(:first-child) {
height: clamp(430px, 58vh, 760px);
}
@@ -1747,6 +1753,72 @@ h3 {
gap: 10px;
}
+.terminal-link-button {
+ text-decoration: none;
+}
+
+.news-list {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.news-row {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: oklch(0.18 0.012 250 / 0.6);
+ color: var(--text);
+ text-align: left;
+ transition: border-color 150ms ease, background 150ms ease;
+}
+
+.news-row:hover {
+ border-color: var(--accent-soft);
+ background: oklch(0.2 0.015 250 / 0.75);
+}
+
+.news-row-head,
+.news-row-meta {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.news-row h3 {
+ margin: 0;
+ font-size: 0.96rem;
+ font-weight: 600;
+}
+
+.news-row-time {
+ color: var(--text-dim);
+ font-family: var(--font-mono), monospace;
+ font-size: 0.78rem;
+}
+
+.news-row-meta {
+ color: var(--text-dim);
+ font-size: 0.78rem;
+}
+
+.news-drawer-body a {
+ color: var(--accent);
+}
+
+.news-drawer-body p,
+.news-drawer-body ul,
+.news-drawer-body ol,
+.news-drawer-body blockquote {
+ margin: 0 0 12px;
+}
+
.synthetic-status-grid strong,
.synthetic-hit-row strong {
font-family: var(--font-mono), monospace;
@@ -1964,6 +2036,7 @@ h3 {
}
.page-grid-home > :nth-child(3),
+ .page-grid-home > :nth-child(4),
.page-grid-tape > :nth-child(1),
.page-grid-replay > :nth-child(1) {
grid-column: auto;
@@ -1973,6 +2046,7 @@ h3 {
.page-grid-home > :nth-child(1),
.page-grid-home > :nth-child(2),
.page-grid-home > :nth-child(3),
+ .page-grid-home > :nth-child(4),
.page-grid-signals > .terminal-pane,
.page-grid-replay > :not(:first-child),
.page-grid-tape > :first-child,
diff --git a/apps/web/app/news/page.tsx b/apps/web/app/news/page.tsx
new file mode 100644
index 0000000..7e06aa8
--- /dev/null
+++ b/apps/web/app/news/page.tsx
@@ -0,0 +1,7 @@
+import { NewsRoute } from "../terminal";
+
+export const dynamic = "force-dynamic";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/web/app/terminal.test.ts b/apps/web/app/terminal.test.ts
index 2be3da8..63918f2 100644
--- a/apps/web/app/terminal.test.ts
+++ b/apps/web/app/terminal.test.ts
@@ -247,6 +247,15 @@ describe("live manifest", () => {
]);
});
+ it("includes news subscriptions on home and /news", () => {
+ expect(getLiveManifest("/", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toContain(
+ "news"
+ );
+ expect(getLiveManifest("/news", "SPY", 60000, buildDefaultFlowFilters()).map((subscription) => subscription.channel)).toEqual([
+ "news"
+ ]);
+ });
+
it("scopes /charts subscriptions to chart channels only", () => {
const channels = getLiveManifest("/charts", "SPY", 60000, buildDefaultFlowFilters()).map(
(subscription) => subscription.channel
@@ -431,6 +440,13 @@ describe("route feature map", () => {
expect(features.equityOverlay).toBe(true);
expect(features.alerts).toBe(false);
});
+
+ it("maps /news to the dedicated news pane", () => {
+ const features = getRouteFeatures("/news");
+ expect(features.news).toBe(true);
+ expect(features.showNewsPane).toBe(true);
+ expect(features.showAlertsPane).toBe(false);
+ });
});
describe("fixed tape virtualization config", () => {
@@ -461,10 +477,11 @@ describe("dark underlying route dependency helper", () => {
});
describe("terminal navigation", () => {
- it("exposes only Home and Tape as top-level destinations", () => {
+ it("exposes Home, Tape, and News as top-level destinations", () => {
expect(NAV_ITEMS).toEqual([
{ href: "/", label: "Home" },
- { href: "/tape", label: "Tape" }
+ { href: "/tape", label: "Tape" },
+ { href: "/news", label: "News" }
]);
});
});
diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx
index e1ee74c..218e149 100644
--- a/apps/web/app/terminal.tsx
+++ b/apps/web/app/terminal.tsx
@@ -33,6 +33,7 @@ import type {
LiveServerMessage,
LiveHotChannelHealthMap,
LiveSubscription,
+ NewsStory,
OptionFlowFilters,
OptionFlowView,
OptionNbboSide,
@@ -158,6 +159,7 @@ type RouteFeatures = {
nbbo: boolean;
equities: boolean;
flow: boolean;
+ news: boolean;
alerts: boolean;
smartMoney: boolean;
classifierHits: boolean;
@@ -168,6 +170,7 @@ type RouteFeatures = {
showOptionsPane: boolean;
showEquitiesPane: boolean;
showFlowPane: boolean;
+ showNewsPane: boolean;
showAlertsPane: boolean;
showClassifierPane: boolean;
showDarkPane: boolean;
@@ -187,6 +190,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
const includeEquitiesFallback = shouldIncludeEquitiesForDarkUnderlyingFallback();
const normalizedPath =
pathname === "/tape" ||
+ pathname === "/news" ||
pathname === "/signals" ||
pathname === "/charts" ||
pathname === "/replay"
@@ -200,6 +204,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
nbbo: true,
equities: true,
flow: true,
+ news: false,
alerts: false,
smartMoney: false,
classifierHits: false,
@@ -210,6 +215,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
showOptionsPane: true,
showEquitiesPane: true,
showFlowPane: true,
+ showNewsPane: false,
showAlertsPane: false,
showClassifierPane: false,
showDarkPane: false,
@@ -220,12 +226,41 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
needsAlertEvidencePrefetch: false,
needsDarkUnderlying: false
};
+ case "/news":
+ return {
+ options: false,
+ nbbo: false,
+ equities: false,
+ flow: false,
+ news: true,
+ alerts: false,
+ smartMoney: false,
+ classifierHits: false,
+ inferredDark: false,
+ equityJoins: false,
+ equityCandles: false,
+ equityOverlay: false,
+ showOptionsPane: false,
+ showEquitiesPane: false,
+ showFlowPane: false,
+ showNewsPane: true,
+ showAlertsPane: false,
+ showClassifierPane: false,
+ showDarkPane: false,
+ showChartPane: false,
+ showFocusPane: false,
+ showReplayConsole: false,
+ needsClassifierDecor: false,
+ needsAlertEvidencePrefetch: false,
+ needsDarkUnderlying: false
+ };
case "/signals":
return {
options: false,
nbbo: false,
equities: includeEquitiesFallback,
flow: false,
+ news: false,
alerts: true,
smartMoney: true,
classifierHits: true,
@@ -236,6 +271,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
showOptionsPane: false,
showEquitiesPane: false,
showFlowPane: false,
+ showNewsPane: false,
showAlertsPane: true,
showClassifierPane: true,
showDarkPane: true,
@@ -252,6 +288,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
nbbo: false,
equities: includeEquitiesFallback,
flow: false,
+ news: false,
alerts: false,
smartMoney: true,
classifierHits: false,
@@ -262,6 +299,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
showOptionsPane: false,
showEquitiesPane: false,
showFlowPane: false,
+ showNewsPane: false,
showAlertsPane: false,
showClassifierPane: false,
showDarkPane: false,
@@ -278,6 +316,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
nbbo: false,
equities: false,
flow: false,
+ news: false,
alerts: false,
smartMoney: false,
classifierHits: false,
@@ -288,6 +327,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
showOptionsPane: true,
showEquitiesPane: false,
showFlowPane: true,
+ showNewsPane: false,
showAlertsPane: true,
showClassifierPane: false,
showDarkPane: false,
@@ -305,6 +345,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
nbbo: false,
equities: true,
flow: false,
+ news: true,
alerts: true,
smartMoney: true,
classifierHits: false,
@@ -315,6 +356,7 @@ export const getRouteFeatures = (pathname: string): RouteFeatures => {
showOptionsPane: false,
showEquitiesPane: true,
showFlowPane: false,
+ showNewsPane: true,
showAlertsPane: true,
showClassifierPane: false,
showDarkPane: false,
@@ -332,6 +374,7 @@ const EMPTY_ALERT_EVENTS: AlertEvent[] = [];
const EMPTY_CLASSIFIER_HIT_EVENTS: ClassifierHitEvent[] = [];
const EMPTY_SMART_MONEY_EVENTS: SmartMoneyEvent[] = [];
const EMPTY_INFERRED_DARK_EVENTS: InferredDarkEvent[] = [];
+const EMPTY_NEWS_STORIES: NewsStory[] = [];
type CandlestickSeries = ReturnType;
@@ -1194,6 +1237,44 @@ const formatDateTime = (ts: number): string => {
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
};
+const isSameLocalDay = (left: number, right: number): boolean => {
+ const a = new Date(left);
+ const b = new Date(right);
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+};
+
+export const formatNewsTimestamp = (ts: number, now = Date.now()): string => {
+ const date = new Date(ts);
+ return isSameLocalDay(ts, now)
+ ? date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })
+ : date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
+};
+
+const sanitizeNewsHtml = (value: string): { html: string; fallbackText: string; sanitized: boolean } => {
+ const fallbackText = value
+ .replace(/