-
+ Workflow
+
Follow one request from message to shipped.
-
+
Amend keeps working after setup. It watches selected sources, groups real demand,
- follows GitHub and Linear, and helps the team update people when the work ships.
+ follows GitHub and Linear, and updates the people who asked when the work ships.
-
- {/* The ask */}
-
-
+
+ {steps.map((step, index) => (
+
+ {index < steps.length - 1 ? (
+
+ ) : null}
-
+ {step.icon}
-
-
{ask.who}
-
- {ask.where} · {ask.when}
-
+
+
+
{step.title}
+ {step.meta ? (
+ {step.meta}
+ ) : null}
+
+ {step.body}
-
- {ask.message}
-
-
- +12 asked
-
- grouped into one thread
-
-
-
- {/* Amend, closing the loop. Connector is vertical on mobile, horizontal on desktop. */}
-
-
-
-
-
-
-
-
- Amend
-
-
-
-
-
+
+ ))}
+
- {/* Shipped */}
-
-
-
-
- {shipped.label}
-
-
-
-
{shipped.title}
-
{shipped.note}
-
-
-
- changelog
-
- email
- in-app widget
-
-
-
-
-
-
- Posts go out by your rule: silent, a quiet receipt, or only after approval.
-
+
+ Posts go out by your rule — silent, a quiet receipt, or only after approval.{" "}
See the source trace
→
-
+
);
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index e1fdf2d..168a743 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -105,7 +105,7 @@
}
/* ----------------------------------------------------------------------------
- Hero — full-bleed cinematic "closing the loop" composition
+ Hero — one calm "closing the loop" image, anchored right, text left
---------------------------------------------------------------------------- */
.amend-hero {
position: relative;
@@ -115,41 +115,20 @@
overflow: hidden;
}
-/* The generated warm-loop photograph, anchored to the right. Painted as a
- background so a still-missing asset degrades cleanly to the CSS aura below
- (no broken-image icon). */
+/* The warm-loop photograph, anchored to the right and held still. */
.amend-hero-photo {
position: absolute;
inset: 0;
- z-index: -3;
- opacity: 0.9;
+ z-index: -2;
+ opacity: 0.92;
background-image: url("/images/hero-loop.webp");
background-size: cover;
- background-position: 76% 44%;
+ background-position: 78% 42%;
background-repeat: no-repeat;
- animation: amend-hero-drift 24s ease-in-out infinite alternate;
}
-/* CSS-built warm signal arc — enhances the photo, and stands alone as a
- premium fallback if the asset is missing. */
-.amend-hero-aura {
- position: absolute;
- inset: -10% -5% -20% 20%;
- z-index: -2;
- pointer-events: none;
- background:
- radial-gradient(
- 60% 55% at 72% 38%,
- rgb(var(--amend-glow) / 0.26) 0%,
- rgb(var(--amend-glow) / 0.08) 38%,
- transparent 68%
- ),
- radial-gradient(40% 60% at 88% 70%, rgb(var(--amend-glow) / 0.12) 0%, transparent 60%);
- filter: blur(8px);
- animation: amend-hero-pulse 9s ease-in-out infinite;
-}
-
-/* Legibility veil: keeps the left text column on calm darkness. */
+/* Legibility veil: dark under the left text column, clearing so the loop reads
+ on the right, with a soft fade into the next section along the bottom edge. */
.amend-hero-veil {
position: absolute;
inset: 0;
@@ -159,44 +138,31 @@
linear-gradient(
90deg,
var(--background) 0%,
- rgb(13 13 15 / 0.92) 34%,
- rgb(13 13 15 / 0.55) 58%,
- transparent 92%
+ rgb(13 13 15 / 0.88) 30%,
+ rgb(13 13 15 / 0.4) 56%,
+ transparent 82%
),
- linear-gradient(to top, var(--background) 2%, transparent 28%),
- linear-gradient(to bottom, var(--background) 0%, transparent 22%);
+ linear-gradient(to bottom, transparent 62%, var(--background) 100%);
}
@media (max-width: 767px) {
.amend-hero-photo {
- object-position: 70% 30%;
- opacity: 0.55;
+ opacity: 0.5;
+ background-position: 72% 30%;
}
.amend-hero-veil {
background:
linear-gradient(
90deg,
- var(--background) 8%,
- rgb(13 13 15 / 0.86) 52%,
- rgb(13 13 15 / 0.62) 100%
+ var(--background) 6%,
+ rgb(13 13 15 / 0.82) 50%,
+ rgb(13 13 15 / 0.5) 100%
),
- linear-gradient(to top, var(--background) 4%, transparent 40%),
- linear-gradient(to bottom, var(--background) 0%, transparent 30%);
+ linear-gradient(to bottom, transparent 55%, var(--background) 100%);
}
}
-/* fine film grain over the whole page, GPU-cheap (fixed, no repaint on scroll) */
-.amend-grain {
- position: fixed;
- inset: 0;
- z-index: 30;
- pointer-events: none;
- opacity: 0.15;
- mix-blend-mode: soft-light;
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
-}
-
/* Hero entrance — staggered rise */
.amend-rise > * {
animation: amend-rise 0.85s var(--amend-ease) both;
@@ -336,27 +302,6 @@
}
}
-@keyframes amend-hero-pulse {
- 0%,
- 100% {
- opacity: 0.85;
- transform: scale(1);
- }
- 50% {
- opacity: 1;
- transform: scale(1.04);
- }
-}
-
-@keyframes amend-hero-drift {
- 0% {
- transform: scale(1.02) translateX(0);
- }
- 100% {
- transform: scale(1.08) translateX(-1.5%);
- }
-}
-
@keyframes amend-marquee {
0% {
transform: translate3d(0, 0, 0);
@@ -376,8 +321,6 @@
animation-iteration-count: 1 !important;
}
- .amend-hero-photo,
- .amend-hero-aura,
.amend-rise > *,
.amend-flowline,
.amend-flowline-v,
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 0a0f33f..346edac 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -56,7 +56,6 @@ function HomeComponent() {
}),
}}
/>
-
From 89d337e5e18bd0c313d87f669590e9fc7187aa64 Mon Sep 17 00:00:00 2001
From: Leo
Date: Thu, 18 Jun 2026 22:03:39 -0400
Subject: [PATCH 2/7] update
---
BACKEND_PLAN.txt | 241 ++++++
FRONTEND_PLAN.txt | 187 ++++
.../web/src/components/amend-agent-chrome.tsx | 68 ++
.../web/src/components/amend-agent-shared.tsx | 431 ++++++++++
.../web/src/components/amend-board-screen.tsx | 410 +++++++++
.../components/amend-dashboard-constants.ts | 11 +-
.../components/amend-dashboard-core-types.ts | 10 +-
.../amend-dashboard-main-workspace.tsx | 9 +
.../amend-dashboard-status-utils.ts | 5 +-
.../src/components/amend-drafts-screen.tsx | 219 +++++
.../src/components/amend-memory-screen.tsx | 294 +++++++
.../components/amend-need-detail-screen.tsx | 402 +++++++++
.../components/dashboard-sidebar-chrome.tsx | 43 +-
.../web/src/components/dashboard-view-nav.tsx | 14 +-
apps/web/src/index.css | 19 +-
apps/web/src/lib/amend-agent-format.ts | 92 ++
apps/web/src/lib/amend-contract.ts | 143 ++++
apps/web/src/lib/icons.tsx | 36 +
apps/web/src/lib/mock-amend.ts | 809 ++++++++++++++++++
packages/backend/convex/_generated/api.d.ts | 34 +
packages/backend/convex/agent.ts | 54 ++
.../amendFeedbackCreateMutationHandler.ts | 13 +
packages/backend/convex/amendSourceIngest.ts | 13 +
packages/backend/convex/backfill.ts | 29 +
packages/backend/convex/changelog.ts | 59 ++
packages/backend/convex/crons.ts | 14 +
packages/backend/convex/digest.ts | 101 +++
packages/backend/convex/drafts.ts | 150 ++++
packages/backend/convex/http.ts | 4 +
packages/backend/convex/ingest.ts | 41 +
packages/backend/convex/memory.ts | 68 ++
packages/backend/convex/needs.ts | 136 +++
packages/backend/convex/pipeline.ts | 208 +++++
packages/backend/convex/proactiveArgs.ts | 51 ++
.../backend/convex/proactiveClassifier.ts | 100 +++
packages/backend/convex/proactiveProof.ts | 130 +++
packages/backend/convex/proactiveShared.ts | 231 +++++
.../backend/convex/proactiveValidators.ts | 157 ++++
.../backend/convex/schemaProactiveTables.ts | 184 ++++
.../backend/convex/schemaWorkspaceTables.ts | 2 +
packages/backend/convex/sources.ts | 41 +
packages/backend/tests/draftSendGate.test.ts | 36 +
.../backend/tests/proactiveClassifier.test.ts | 37 +
43 files changed, 5329 insertions(+), 7 deletions(-)
create mode 100644 BACKEND_PLAN.txt
create mode 100644 FRONTEND_PLAN.txt
create mode 100644 apps/web/src/components/amend-agent-chrome.tsx
create mode 100644 apps/web/src/components/amend-agent-shared.tsx
create mode 100644 apps/web/src/components/amend-board-screen.tsx
create mode 100644 apps/web/src/components/amend-drafts-screen.tsx
create mode 100644 apps/web/src/components/amend-memory-screen.tsx
create mode 100644 apps/web/src/components/amend-need-detail-screen.tsx
create mode 100644 apps/web/src/lib/amend-agent-format.ts
create mode 100644 apps/web/src/lib/amend-contract.ts
create mode 100644 apps/web/src/lib/mock-amend.ts
create mode 100644 packages/backend/convex/agent.ts
create mode 100644 packages/backend/convex/backfill.ts
create mode 100644 packages/backend/convex/changelog.ts
create mode 100644 packages/backend/convex/crons.ts
create mode 100644 packages/backend/convex/digest.ts
create mode 100644 packages/backend/convex/drafts.ts
create mode 100644 packages/backend/convex/ingest.ts
create mode 100644 packages/backend/convex/memory.ts
create mode 100644 packages/backend/convex/needs.ts
create mode 100644 packages/backend/convex/pipeline.ts
create mode 100644 packages/backend/convex/proactiveArgs.ts
create mode 100644 packages/backend/convex/proactiveClassifier.ts
create mode 100644 packages/backend/convex/proactiveProof.ts
create mode 100644 packages/backend/convex/proactiveShared.ts
create mode 100644 packages/backend/convex/proactiveValidators.ts
create mode 100644 packages/backend/convex/schemaProactiveTables.ts
create mode 100644 packages/backend/convex/sources.ts
create mode 100644 packages/backend/tests/draftSendGate.test.ts
create mode 100644 packages/backend/tests/proactiveClassifier.test.ts
diff --git a/BACKEND_PLAN.txt b/BACKEND_PLAN.txt
new file mode 100644
index 0000000..e6667ad
--- /dev/null
+++ b/BACKEND_PLAN.txt
@@ -0,0 +1,241 @@
+===============================================================================
+AMEND — PROACTIVE AGENT · BACKEND PLAN (v1)
+Stack: Convex (packages/backend/convex). TypeScript. Better Auth. PostHog.
+Pairs with: FRONTEND_PLAN.txt (same CONTRACT block lives in both — keep in sync)
+===============================================================================
+
+-------------------------------------------------------------------------------
+COORDINATION PROTOCOL — READ FIRST (this is how the two plans don't collide)
+-------------------------------------------------------------------------------
+1. OWNERSHIP. Backend touches ONLY `packages/backend/convex/**`. It never edits
+ anything under `apps/web/**`. Frontend never edits `convex/**`. No shared
+ source files except the CONTRACT (below), which is the single agreed seam.
+
+2. THE CONTRACT IS THE SEAM. The only thing the two sides share is the set of
+ Convex query/mutation names + their argument and return shapes (the CONTRACT
+ section). Frontend codes against those shapes; backend implements them.
+ Nothing else crosses the line.
+
+3. STUBS FIRST (unblocks frontend on day one). Backend Phase 0 ships every
+ CONTRACT function as a STUB that returns fixture data of the correct shape.
+ The moment P0 lands, frontend can call real `api.*` functions and get
+ correctly-typed (fake) data, then we fill in real logic behind the same
+ signatures. Frontend is NEVER blocked waiting on backend logic.
+
+4. CHANGING THE CONTRACT. If a signature/shape must change, update the CONTRACT
+ block in BOTH files in the same commit and tell the other side. Never change
+ a return shape silently — that is the one thing that breaks the other side.
+
+5. INTEGRATION HANDSHAKE. Per screen, frontend flips one data hook from its
+ mock layer to `useQuery(api.x.y)`. Because the stub already returns the real
+ shape, that swap is a no-op for the UI. Do screens one at a time.
+
+-------------------------------------------------------------------------------
+CONTRACT — the Convex API surface (IDENTICAL in FRONTEND_PLAN.txt)
+-------------------------------------------------------------------------------
+All functions are workspace-scoped; `workspaceId` is resolved from the auth'd
+session server-side (frontend passes it explicitly only where noted). Types are
+the RETURN shapes the frontend renders. Treat these as load-bearing.
+
+ // ---- shared data shapes ----
+ Ghost = {
+ id, workspaceId, title,
+ status: "ghost" | "accepted" | "killed",
+ proof: {
+ people: number, // distinct identity-verified people
+ payingPeople: number, // subset that map to a paying account
+ sources: { channel: "discord"|"support"|"github"|"embed", count: number }[],
+ strength: "thin" | "building" | "strong", // derived, NOT a raw float
+ growthPerWeek: number
+ },
+ sampleQuotes: { text, author, channel, url }[], // max 3
+ firstSeen: number, lastSeen: number
+ }
+
+ Need = Ghost & { // an accepted ghost
+ evidence: Evidence[],
+ linkedShip?: ShipLink
+ }
+
+ Evidence = { id, sourceChannel, author, text, url,
+ confidenceBucket: "clear"|"worth-a-look"|"unsure",
+ promotedBy: "agent"|"human" }
+
+ ShipLink = { prNumber, sha, releaseTag?, mergedAt, url }
+
+ DraftProposal = { id, kind: "changelog"|"notify",
+ needId, needTitle, draftText,
+ recipients?: { handle, channel }[],
+ status: "pending"|"approved"|"rejected" }
+
+ ChangelogEntry = { id, title, body, shippedAt, ship: ShipLink, published: boolean }
+
+ MemoryRule = { id, kind: "noise"|"dedupe"|"addressed"|"allowlist"|"pattern",
+ text, taughtBy, taughtAt, blastRadius: number, enabled: boolean }
+
+ DigestPreview = {
+ period: { from, to },
+ resolved: { needTitle, ship: ShipLink, peopleNotified: number }[],
+ readyGhosts: Ghost[],
+ handledSilently: number
+ }
+
+ // ---- QUERIES (frontend reads; reactive) ----
+ needs.listGhosts({ }) -> Ghost[] // status === "ghost", sorted by strength
+ needs.listAccepted({ }) -> Need[] // status === "accepted"
+ needs.get({ needId }) -> Need | null // full evidence trail
+ drafts.listPending({ }) -> DraftProposal[]
+ changelog.list({ }) -> ChangelogEntry[]
+ memory.listRules({ }) -> MemoryRule[]
+ digest.preview({ }) -> DigestPreview
+ sources.status({ }) -> { github: boolean, feedback: boolean }
+
+ // ---- MUTATIONS (user actions from the UI) ----
+ needs.acceptGhost({ ghostId }) -> { ok: true }
+ needs.keepGathering({ ghostId }) -> { ok: true }
+ needs.killGhost({ ghostId, reason? }) -> { ok: true } // writes noise: memory
+ drafts.approve({ draftId }) -> { ok: true } // triggers the actual send
+ drafts.reject({ draftId, edits? }) -> { ok: true }
+ memory.toggleRule({ ruleId, enabled }) -> { ok: true }
+ memory.undoRule({ ruleId }) -> { ok: true }
+ changelog.publish({ entryId }) -> { ok: true }
+ sources.connectGithub({ repo, token }) -> { ok: true } // kicks off backfill
+
+ // ---- INTERNAL (webhooks / cron / pipeline — UI NEVER calls these) ----
+ httpAction ingest.githubWebhook
+ httpAction ingest.discordWebhook
+ internalAction pipeline.processEvent
+ internalAction backfill.gitHistory
+ internalMutation agent.linkShipsToNeeds
+ cron digest.sendWeekly
+
+===============================================================================
+BUILD PHASES (sequenced so frontend is unblocked early, hard parts come later)
+===============================================================================
+
+PHASE 0 — SCHEMA + CONTRACT STUBS (do this first; unblocks frontend immediately)
+-------------------------------------------------------------------------------
+GOAL: every CONTRACT query/mutation exists and returns correctly-shaped fixture
+data. No real logic yet.
+- Extend schema in `convex/schema*.ts`. New tables (reuse existing automation
+ tables where they fit — automationDecisions ≈ DraftProposal, agentRuns = run
+ ledger):
+ needs (the ghost/need; status, title, denormalized proof.* fields,
+ conditionFlags for cheap trigger checks)
+ evidence (many-to-many join: needId, sourceEventId, person, channel,
+ handle, text, url, confidenceBucket, promotedBy)
+ needVectors (SEPARATE table: needId, embedding[]; vector index, filter
+ field = workspaceId) // vectors are large; keep them apart
+ sourceEvents (raw verbatim ingest + thin normalized fields; immutable)
+ persons + identityHandles (identity graph; deterministic joins)
+ shipLinks (needId, prNumber, sha, releaseTag, mergedAt, url)
+ proposals (the human-gated drafts; reuse/extend automationDecisions)
+ changelogEntries
+ memoryRules (kind, text, taughtBy, taughtAt, enabled, version history)
+- Write all CONTRACT functions as stubs returning fixtures of the right shape.
+- DELIVERABLE: `bunx convex dev` typechecks; `api.needs.listGhosts` returns
+ 3 fake ghosts. Tell frontend P0 is live.
+GOTCHAS: put `workspaceId` in vector index `filterFields`. One accent: do not
+let the frontend import anything but generated `api` — keep internals private.
+
+PHASE 1 — GITHUB + GIT-HISTORY BACKFILL (the wedge + the cold-start fix)
+-------------------------------------------------------------------------------
+GOAL: connect a repo, replay 90 days of merged PRs/releases, populate the board.
+- `sources.connectGithub` stores the connection, schedules `backfill.gitHistory`.
+- `backfill.gitHistory`: CHUNKED + self-rescheduling (Convex action 10-min cap —
+ never backfill in one action). For each merged PR: create a shipLink; if the
+ PR body / linked issues mention a requester or an existing need, draft a
+ ChangelogEntry. Produces the "you closed #12 and never told the 3 people" moment.
+- `ingest.githubWebhook`: HTTP action that ONLY enqueues (validate signature,
+ store raw sourceEvent, schedule pipeline). Idempotent on delivery id.
+DELIVERABLE: connect repo -> changelog list populates from real history.
+GOTCHAS: chunk backfill; idempotent handlers; never call OpenAI in the webhook.
+
+PHASE 2 — INGEST ONE FEEDBACK SOURCE + THE FUNNEL
+-------------------------------------------------------------------------------
+GOAL: one feedback inbound (the embed/SDK for v1; stable IDs + identity).
+- The funnel before any expensive call:
+ Stage 0 free heuristics (bot/length/channel/regex) -> drop 70-90%
+ Stage 1 embedding-centroid gate (is-feedback vs chatter exemplars)
+ Stage 2 chat LLM ONLY on the ambiguous middle
+- Route all model work through a bounded Workpool (concurrency 5-10) so bursts
+ queue, not stampede. Every handler idempotent (dedupe key = source msg id).
+DELIVERABLE: a submitted feedback item flows in and becomes a sourceEvent that
+survives the funnel.
+
+PHASE 3 — DEDUP ENGINE + GHOST PROOF ACCUMULATION (the hard core)
+-------------------------------------------------------------------------------
+GOAL: turn surviving signal into ghosts that accumulate proof correctly.
+- classify -> retrieve -> judge -> commit:
+ classify into fixed schema (type, sentiment, facets: area/verb/polarity/platform)
+ retrieve top-K from needVectors (filter workspaceId)
+ judge with LLM ONLY when cosine in 0.80-0.95 band
+ facet guard (hardcoded): refuse merge across facet mismatch even at 0.97
+ COMMIT via transactional MUTATION keyed by deterministic cluster key
+ (NOT in the action) -> closes the concurrent false-split race
+- Recompute denormalized proof.* on the need each time evidence attaches:
+ people, payingPeople, sources[], strength (thin/building/strong = a derived
+ bucket, never a raw float exposed), growthPerWeek.
+DELIVERABLE: two paraphrases of one need merge; "export" vs "import" do NOT.
+`needs.listGhosts` returns real ghosts with real proof.
+
+PHASE 4 — IDENTITY (deterministic-first) + PAYING FLAG
+-------------------------------------------------------------------------------
+GOAL: proof counts distinct VERIFIED people, and knows who pays.
+- persons/identityHandles via deterministic joins ONLY for v1: verified email,
+ OAuth-linked account, commit-email == ticket-email, exact handle. Fuzzy
+ inference is a non-auto suggestion (defer).
+- payingPeople = persons mapped to a billing/paying account (flag for now).
+DELIVERABLE: proof.people = distinct persons; proof.payingPeople correct.
+
+PHASE 5 — THE WALL + HUMAN-GATED PROPOSALS
+-------------------------------------------------------------------------------
+GOAL: nothing reaches a customer/public surface without a human + a safety gate.
+- On accept/resolution, agent DRAFTS a proposal (changelog | notify). The LLM
+ emits a proposal row; it never sends. `drafts.approve` is the only path that
+ performs the real send / publish (deterministic code).
+- Safety gate before any external crossing: PII/secret scan, paraphrase +
+ attribution-strip for public; support-sourced evidence NEVER auto-publishes.
+DELIVERABLE: `drafts.listPending` returns real drafts; approve actually sends.
+
+PHASE 6 — MEMORY SYSTEM
+-------------------------------------------------------------------------------
+GOAL: learn from corrections, legibly and reversibly.
+- killGhost(reason) / explicit "always noise" writes a memoryRule (kind=noise),
+ with taughtBy/taughtAt and a computed blastRadius (how many items it hides).
+- Pipeline reads memoryRules before deciding (suppress / auto-attach / escalate).
+- Rules are versioned + reversible (`memory.undoRule`); "not now" != durable
+ suppression (durable = admin action). Memory written from validated decisions
+ only, never raw model free-text.
+DELIVERABLE: `memory.listRules` real; toggling a rule changes pipeline behavior.
+
+PHASE 7 — PROACTIVITY TRIGGERS + WEEKLY DIGEST
+-------------------------------------------------------------------------------
+GOAL: the push retention hook + honest triggers.
+- Reactive: in-mutation condition checks schedule the actor (weight crossed /
+ linked PR merged). Scheduled sweep (cron) for time/absence/aggregate.
+- `agent.linkShipsToNeeds`: when a PR merges, attach the shipLink to matching
+ needs, draft the "we shipped your thing" proposal.
+- `digest.sendWeekly` (cron): compose DigestPreview, send via email/Slack.
+ THIS is the retention hook — measure open rate (the make-or-break metric).
+DELIVERABLE: weekly digest sends; `digest.preview` powers the in-app preview.
+
+-------------------------------------------------------------------------------
+VALIDATION (run the narrowest relevant check each phase)
+-------------------------------------------------------------------------------
+- `bunx convex dev` typechecks after every phase.
+- Phase 3: unit-test the facet guard (export/import stay split) + the
+ transactional commit under two concurrent inserts.
+- Phase 5: assert no function except `drafts.approve` can perform an external
+ send (grep for the send call; it lives in exactly one place behind the gate).
+- Phase 7: trigger a test PR merge -> confirm a draft proposal appears, not a
+ sent message.
+
+-------------------------------------------------------------------------------
+DO / DON'T
+-------------------------------------------------------------------------------
+DO: keep the pipeline hardcoded TS; LLM only at classify + pairwise-judge with
+ fixed prompts; treat all source text as untrusted delimited data.
+DON'T: edit apps/web/**. DON'T change a CONTRACT return shape without updating
+ FRONTEND_PLAN.txt in the same commit. DON'T let the LLM call tools or send.
+===============================================================================
diff --git a/FRONTEND_PLAN.txt b/FRONTEND_PLAN.txt
new file mode 100644
index 0000000..2382f5f
--- /dev/null
+++ b/FRONTEND_PLAN.txt
@@ -0,0 +1,187 @@
+===============================================================================
+AMEND — PROACTIVE AGENT · FRONTEND PLAN (v1)
+Stack: TanStack Start + React 19 + TanStack Query (apps/web). Tailwind v4 +
+ shadcn/ui (packages/ui). Icons via @/lib/icons (Hugeicons — NEVER lucide).
+Pairs with: BACKEND_PLAN.txt (same CONTRACT block lives in both — keep in sync)
+===============================================================================
+
+-------------------------------------------------------------------------------
+COORDINATION PROTOCOL — READ FIRST (this is how the two plans don't collide)
+-------------------------------------------------------------------------------
+1. OWNERSHIP. Frontend touches ONLY `apps/web/**` (+ shared components in
+ `packages/ui` if needed). It never edits `packages/backend/convex/**`.
+ Backend never edits `apps/web/**`. The CONTRACT is the only shared seam.
+
+2. THE CONTRACT IS THE SEAM. Build every screen against the CONTRACT shapes
+ below — the Convex query/mutation names + their return types. Do not assume
+ anything about backend internals (tables, pipeline, LLM). If you need data
+ the CONTRACT doesn't expose, that's a CONTRACT change (rule 4), not a guess.
+
+3. NEVER BLOCKED ON BACKEND. Phase 0 builds a mock data layer returning fixtures
+ that match the CONTRACT exactly. Every screen is fully buildable + reviewable
+ against mocks before backend logic exists. You ship the whole UI on fixtures.
+
+4. CHANGING THE CONTRACT. If a screen needs a new field/function, update the
+ CONTRACT block in BOTH files in the same commit and tell backend. Never
+ render off a shape backend didn't agree to — that's the thing that desyncs.
+
+5. INTEGRATION HANDSHAKE. Once backend Phase 0 stubs are live, flip ONE data
+ hook per screen from the mock layer to `useQuery(api.x.y)`. Because the stub
+ returns the real shape, the swap is invisible to the component. One screen at
+ a time; the rest keep running on mocks until their backend function is ready.
+
+-------------------------------------------------------------------------------
+CONTRACT — the Convex API surface (IDENTICAL in BACKEND_PLAN.txt)
+-------------------------------------------------------------------------------
+RETURN shapes you render. Workspace is resolved server-side. These are
+load-bearing — render exactly these, nothing more.
+
+ Ghost = {
+ id, workspaceId, title,
+ status: "ghost" | "accepted" | "killed",
+ proof: {
+ people: number, payingPeople: number,
+ sources: { channel: "discord"|"support"|"github"|"embed", count: number }[],
+ strength: "thin" | "building" | "strong", // render as a label/bar, never a number
+ growthPerWeek: number
+ },
+ sampleQuotes: { text, author, channel, url }[], // max 3
+ firstSeen: number, lastSeen: number
+ }
+ Need = Ghost & { evidence: Evidence[], linkedShip?: ShipLink }
+ Evidence = { id, sourceChannel, author, text, url,
+ confidenceBucket: "clear"|"worth-a-look"|"unsure", promotedBy: "agent"|"human" }
+ ShipLink = { prNumber, sha, releaseTag?, mergedAt, url }
+ DraftProposal = { id, kind: "changelog"|"notify", needId, needTitle, draftText,
+ recipients?: { handle, channel }[], status: "pending"|"approved"|"rejected" }
+ ChangelogEntry = { id, title, body, shippedAt, ship: ShipLink, published: boolean }
+ MemoryRule = { id, kind: "noise"|"dedupe"|"addressed"|"allowlist"|"pattern",
+ text, taughtBy, taughtAt, blastRadius: number, enabled: boolean }
+ DigestPreview = { period:{from,to}, resolved:{needTitle,ship:ShipLink,peopleNotified}[],
+ readyGhosts: Ghost[], handledSilently: number }
+
+ QUERIES (reactive reads):
+ needs.listGhosts({}) -> Ghost[] needs.listAccepted({}) -> Need[]
+ needs.get({needId}) -> Need|null drafts.listPending({}) -> DraftProposal[]
+ changelog.list({}) -> ChangelogEntry[] memory.listRules({}) -> MemoryRule[]
+ digest.preview({}) -> DigestPreview sources.status({}) -> {github,feedback}
+
+ MUTATIONS (user actions):
+ needs.acceptGhost({ghostId}) needs.keepGathering({ghostId})
+ needs.killGhost({ghostId,reason?}) drafts.approve({draftId})
+ drafts.reject({draftId,edits?}) memory.toggleRule({ruleId,enabled})
+ memory.undoRule({ruleId}) changelog.publish({entryId})
+ sources.connectGithub({repo,token})
+
+ INTERNAL (webhooks/cron) — the UI NEVER calls these. Do not reference them.
+
+===============================================================================
+DESIGN POSTURE (so it doesn't look AI-generated — match existing Amend theme)
+===============================================================================
+- Calm, premium, rounded surfaces + ring-1 white/low-opacity borders (the current
+ theme; square border-border is legacy). Compact sans; MONO for data/IDs/SHAs.
+- One restrained accent. Status color does semantic work (ghost=faint/dashed,
+ ready=accent, paying=warm).
+- NO confidence decimals ever. Render strength as Thin / Building / Strong + a
+ bar, with a plain because-line ("12 people, 3 paying, across 3 sources").
+- The board is a BRIEFING not an inbox: lead with what the agent handled, not a
+ pile of pending work. No marketing hero, no card-soup.
+- Every list has real loading (skeleton), empty, and error states.
+
+===============================================================================
+BUILD PHASES (every screen ships on mocks; swap to api.* per screen at the end)
+===============================================================================
+
+PHASE 0 — MOCK DATA LAYER + ROUTE SKELETON (do first; zero backend needed)
+-------------------------------------------------------------------------------
+- `apps/web/src/lib/amend-contract.ts`: TS interfaces copied from the CONTRACT
+ (single source the components import). This is your typed seam.
+- `apps/web/src/lib/mock-amend.ts`: fixtures matching every CONTRACT return shape
+ (3 ghosts at different strengths, 2 accepted needs, 2 drafts, changelog, memory
+ rules, a digest). A `useAmend(queryName)` hook that returns mock data now and
+ will swap to `useQuery(api...)` later — keep the swap to ONE file.
+- Routes (TanStack Start file-based): /board, /board/$needId, /drafts,
+ /changelog, /memory, /settings.
+DELIVERABLE: app runs, all routes render fixtures, fully clickable. No backend.
+
+PHASE 1 — THE GHOST BOARD (the hero screen — invest the most here)
+-------------------------------------------------------------------------------
+Route: /board. Consumes: needs.listGhosts, needs.listAccepted,
+ needs.acceptGhost, needs.keepGathering, needs.killGhost.
+- "Gathering proof" section: GHOST cards = faint, dashed border, ribbon
+ ("Just forming" vs pulsing "Ready when you are"). Each shows: title, the proof
+ line (people + payingPeople), source chips with counts, a strength bar
+ (thin/building/strong — NOT a %), growthPerWeek ("4 new this week"), one sample
+ quote. Actions: Add to board / Keep gathering / Not a thing.
+ -> "thin" strength = Add disabled (must earn it). optimistic update on accept.
+- "On the board" section: accepted Needs (solid), linkedShip badge if present.
+DELIVERABLE: the ghost board from the showcase, on real fixture data, with
+working accept/keep/kill (optimistic).
+
+PHASE 2 — NEED DETAIL / EVIDENCE TRAIL
+-------------------------------------------------------------------------------
+Route: /board/$needId. Consumes: needs.get.
+- SOURCES-FIRST (the differentiation — don't bury it). Headline = computed
+ synthesis ("9 people across Discord, GitHub, support — 3 paying — first raised
+ Mar 2, active this week"). Evidence grouped by source, collapsed, 2 quotes
+ inline. People (avatars) are the spine, not messages. linkedShip with the SHA.
+DELIVERABLE: click a ghost/need -> readable evidence brief.
+
+PHASE 3 — DRAFT-REVIEW LANE
+-------------------------------------------------------------------------------
+Route: /drafts. Consumes: drafts.listPending, drafts.approve, drafts.reject.
+- Each pending DraftProposal: kind (changelog/notify), the need it's for, the
+ draft text (editable), recipients for notify. Approve / Edit+approve / Reject.
+- Make clear nothing sends until Approve (the trust surface). Pre-send hold note.
+DELIVERABLE: review queue works on fixtures; approve/reject call mutations.
+
+PHASE 4 — PUBLIC CHANGELOG / PORTAL
+-------------------------------------------------------------------------------
+Route: /changelog. Consumes: changelog.list, changelog.publish.
+- Entries with title/body/shippedAt + the SHA proof link (the wedge made visible).
+ Published vs draft state. This is also the public-facing surface (themeable).
+DELIVERABLE: changelog renders; publish toggles state.
+
+PHASE 5 — MEMORY UI ("what Amend learned")
+-------------------------------------------------------------------------------
+Route: /memory. Consumes: memory.listRules, memory.toggleRule, memory.undoRule.
+- Browsable rules grouped by kind (noise/dedupe/addressed/allowlist/pattern), in
+ PLAIN LANGUAGE, each with who taught it, when, blastRadius ("hides ~212/mo"),
+ a toggle, undo. The teaching receipt toast (on kill/correct). The audit nudge
+ ("this rule hid 40 items in 3 weeks — still right?").
+DELIVERABLE: the memory showcase, on real fixture rules, toggles working.
+
+PHASE 6 — SETTINGS + DIGEST PREVIEW
+-------------------------------------------------------------------------------
+Routes: /settings (+ a digest preview block). Consumes: sources.status,
+ sources.connectGithub, digest.preview.
+- Connect GitHub (the cold-start entry point). Source status. Conservative/
+ Balanced/Aggressive presets (NO raw numeric thresholds in onboarding).
+- Digest preview: "3 asks resolved this week + proof; 1 ghost ready" — the thing
+ that gets pushed to Slack/email. (Sending is backend; this is the in-app view.)
+DELIVERABLE: settings + a preview of the weekly digest.
+
+PHASE 7 — INTEGRATION SWAP (per screen, after backend P0+ lands)
+-------------------------------------------------------------------------------
+- For each screen whose backend function is implemented, flip `useAmend` from
+ mock to `useQuery(api.x.y)` / `useMutation`. Verify the shape matches (it will,
+ if both CONTRACT blocks agree). Keep un-ready screens on mocks.
+DELIVERABLE: screens go live one by one with zero component rewrites.
+
+-------------------------------------------------------------------------------
+VALIDATION
+-------------------------------------------------------------------------------
+- Typecheck + lint after every phase (oxlint). App runs on mocks end-to-end.
+- Screenshot review (desktop + mobile) against the design posture each screen —
+ reject card-soup, raw decimals, buried evidence, empty-looking board.
+- Phase 1 + 5 are the polish-critical screens; do an extra visual pass.
+
+-------------------------------------------------------------------------------
+DO / DON'T
+-------------------------------------------------------------------------------
+DO: build the whole UI on fixtures first; keep the mock->api swap in ONE hook;
+ render exactly the CONTRACT shapes; icons from @/lib/icons.
+DON'T: edit packages/backend/convex/**. DON'T import lucide-react. DON'T show
+ confidence decimals. DON'T add fields the CONTRACT doesn't define without
+ updating BACKEND_PLAN.txt in the same commit.
+===============================================================================
diff --git a/apps/web/src/components/amend-agent-chrome.tsx b/apps/web/src/components/amend-agent-chrome.tsx
new file mode 100644
index 0000000..aae49cd
--- /dev/null
+++ b/apps/web/src/components/amend-agent-chrome.tsx
@@ -0,0 +1,68 @@
+/**
+ * Page chrome for the agent views, rendered inside the existing dashboard
+ * content column (so it sits under the shared sidebar + workspace switcher).
+ * Each agent view composes + the same way the
+ * existing feedback/roadmap/changelog workspaces compose their header + body.
+ */
+import { cn } from "@amend/ui/lib/utils";
+import type { ReactNode } from "react";
+
+import type { LucideIcon } from "@/lib/icons";
+
+export function PageHeader({
+ icon: Icon,
+ title,
+ subtitle,
+ actions,
+}: {
+ icon?: LucideIcon;
+ title: ReactNode;
+ subtitle?: ReactNode;
+ actions?: ReactNode;
+}) {
+ return (
+
+
+ {Icon ? (
+
+
+
+ ) : null}
+
+
+ {title}
+
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+
+
+ {actions ? {actions}
: null}
+
+ );
+}
+
+/** Scrollable body with a restrained, reduced-motion-aware view-enter. */
+export function PageScroll({
+ children,
+ className,
+ routeKey,
+}: {
+ children: ReactNode;
+ className?: string;
+ routeKey?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/amend-agent-shared.tsx b/apps/web/src/components/amend-agent-shared.tsx
new file mode 100644
index 0000000..fcc201b
--- /dev/null
+++ b/apps/web/src/components/amend-agent-shared.tsx
@@ -0,0 +1,431 @@
+/**
+ * Shared primitives for the proactive-agent console.
+ *
+ * One small design system, locked to the existing Amend dark theme: rounded
+ * surfaces, hairline white rings, mono for data, a single warm accent for
+ * "paying" and a single success accent for "ready". Status color does the
+ * semantic work; everything else stays grayscale on purpose.
+ */
+import { cn } from "@amend/ui/lib/utils";
+import type { ComponentProps, ReactNode } from "react";
+
+import {
+ AlertCircle,
+ Discord,
+ Github,
+ GitMerge,
+ Globe,
+ LifeBuoy,
+ Loader2,
+ Sparkles,
+ type LucideIcon,
+} from "@/lib/icons";
+import type {
+ ConfidenceBucket,
+ Proof,
+ ProofStrength,
+ ShipLink,
+ SourceChannel,
+} from "@/lib/amend-contract";
+import { formatDayMonth, strengthLabel, strengthSegments } from "@/lib/amend-agent-format";
+
+// ---------------------------------------------------------------------------
+// Source channels
+// ---------------------------------------------------------------------------
+
+export const channelMeta: Record = {
+ discord: { label: "Discord", Icon: Discord },
+ support: { label: "Support", Icon: LifeBuoy },
+ github: { label: "GitHub", Icon: Github },
+ embed: { label: "Embed", Icon: Globe },
+};
+
+export function ChannelGlyph({
+ channel,
+ className,
+}: {
+ channel: SourceChannel;
+ className?: string;
+}) {
+ const { Icon, label } = channelMeta[channel];
+ return ;
+}
+
+/** Neutral source chip with a mono count — color stays reserved for status. */
+export function SourceChip({ channel, count }: { channel: SourceChannel; count: number }) {
+ const { label } = channelMeta[channel];
+ return (
+
+
+ {label}
+ {count}
+
+ );
+}
+
+export function SourceChips({
+ sources,
+ className,
+}: {
+ sources: { channel: SourceChannel; count: number }[];
+ className?: string;
+}) {
+ return (
+
+ {sources.map((s) => (
+
+ ))}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Proof: strength meter + because-line
+// ---------------------------------------------------------------------------
+
+const strengthFill: Record = {
+ thin: "bg-muted-foreground/45",
+ building: "bg-foreground/70",
+ strong: "bg-amend-success",
+};
+
+export function StrengthMeter({
+ strength,
+ withLabel = true,
+ className,
+}: {
+ strength: ProofStrength;
+ withLabel?: boolean;
+ className?: string;
+}) {
+ const filled = strengthSegments(strength);
+ return (
+
+
+ {[0, 1, 2].map((i) => (
+
+ ))}
+
+ {withLabel ? (
+
+ {strengthLabel(strength)}
+
+ ) : null}
+
+ );
+}
+
+/** "12 people · 3 paying · across 3 sources" — paying carries the warm accent. */
+export function ProofLine({ proof, className }: { proof: Proof; className?: string }) {
+ return (
+
+ {proof.people} {" "}
+ people
+ {proof.payingPeople > 0 ? (
+ <>
+ {" · "}
+
+ {proof.payingPeople} paying
+
+ >
+ ) : null}
+ {" · across "}
+
+ {proof.sources.length}
+ {" "}
+ {proof.sources.length === 1 ? "source" : "sources"}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// People spine — avatars
+// ---------------------------------------------------------------------------
+
+export function initialsOf(name: string): string {
+ const clean = name
+ .replace(/^@/, "")
+ .replace(/[·•|].*/, "")
+ .trim();
+ const parts = clean.split(/[\s_\-./]+/).filter(Boolean);
+ if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
+ return clean.slice(0, 2).toUpperCase() || "?";
+}
+
+const avatarSize = {
+ sm: "size-6 text-[0.58rem]",
+ md: "size-7 text-[0.62rem]",
+ lg: "size-9 text-[0.7rem]",
+} as const;
+
+export function Avatar({
+ name,
+ size = "md",
+ className,
+ title,
+}: {
+ name: string;
+ size?: keyof typeof avatarSize;
+ className?: string;
+ title?: string;
+}) {
+ return (
+
+ {initialsOf(name)}
+
+ );
+}
+
+export function AvatarStack({
+ names,
+ max = 5,
+ size = "md",
+}: {
+ names: string[];
+ max?: number;
+ size?: keyof typeof avatarSize;
+}) {
+ const shown = names.slice(0, max);
+ const extra = names.length - shown.length;
+ return (
+
+ {shown.map((name, i) => (
+
0 && "-ml-2")}
+ />
+ ))}
+ {extra > 0 ? (
+
+ +{extra}
+
+ ) : null}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Ship proof (SHA)
+// ---------------------------------------------------------------------------
+
+export function ShaChip({ ship, className }: { ship: ShipLink; className?: string }) {
+ return (
+
+
+ #{ship.prNumber}
+
+ {ship.sha}
+
+ {ship.releaseTag ? (
+
+ {ship.releaseTag}
+
+ ) : null}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Confidence
+// ---------------------------------------------------------------------------
+
+export const confidenceMeta: Record = {
+ clear: { label: "Clear", dot: "bg-foreground" },
+ "worth-a-look": { label: "Worth a look", dot: "bg-muted-foreground" },
+ unsure: { label: "Unsure", dot: "bg-transparent ring-1 ring-muted-foreground/55 ring-inset" },
+};
+
+export function ConfidenceTag({ bucket }: { bucket: ConfidenceBucket }) {
+ const { label, dot } = confidenceMeta[bucket];
+ return (
+
+
+ {label}
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Agent identity mark
+// ---------------------------------------------------------------------------
+
+export function AgentMark({ className }: { className?: string }) {
+ return (
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Buttons (rounded theme — the square @amend/ui Button is legacy)
+// ---------------------------------------------------------------------------
+
+type ActionVariant = "primary" | "secondary" | "ghost" | "success" | "danger";
+type ActionSize = "sm" | "md";
+
+const actionBase =
+ "inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg font-semibold transition-colors duration-150 ease-linear outline-none select-none active:opacity-75 disabled:pointer-events-none disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ring/45 [&_svg]:shrink-0";
+
+const actionVariant: Record = {
+ primary: "border border-foreground bg-foreground text-background hover:bg-foreground/85",
+ secondary:
+ "bg-white/[0.04] text-foreground ring-1 ring-white/[0.09] ring-inset hover:bg-white/[0.07]",
+ ghost: "text-muted-foreground hover:bg-white/[0.05] hover:text-foreground",
+ success:
+ "border border-amend-success/25 bg-amend-success/12 text-amend-success hover:bg-amend-success/[0.18]",
+ danger: "text-muted-foreground hover:bg-destructive/12 hover:text-destructive",
+};
+
+const actionSize: Record = {
+ sm: "h-7 px-2.5 text-[0.72rem] [&_svg]:size-3.5",
+ md: "h-8 px-3 text-xs [&_svg]:size-4",
+};
+
+export function agentButtonClass(
+ variant: ActionVariant = "secondary",
+ size: ActionSize = "md",
+ className?: string,
+) {
+ return cn(actionBase, actionVariant[variant], actionSize[size], className);
+}
+
+export function ActionButton({
+ variant = "secondary",
+ size = "md",
+ className,
+ ...props
+}: ComponentProps<"button"> & { variant?: ActionVariant; size?: ActionSize }) {
+ return ;
+}
+
+export function IconButton({ className, ...props }: ComponentProps<"button">) {
+ return (
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Surfaces, labels, states
+// ---------------------------------------------------------------------------
+
+export function SectionLabel({
+ children,
+ count,
+ className,
+}: {
+ children: ReactNode;
+ count?: number;
+ className?: string;
+}) {
+ return (
+
+
+ {children}
+
+ {typeof count === "number" ? (
+
+ {count}
+
+ ) : null}
+
+ );
+}
+
+export function SkeletonBar({ className }: { className?: string }) {
+ return
;
+}
+
+export function EmptyState({
+ icon: Icon = Sparkles,
+ title,
+ hint,
+ action,
+}: {
+ icon?: LucideIcon;
+ title: string;
+ hint?: string;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
+
+
{title}
+ {hint ?
{hint}
: null}
+ {action ?
{action}
: null}
+
+ );
+}
+
+export function ErrorState({ onRetry }: { onRetry?: () => void }) {
+ return (
+
+
+
+
+
Couldn't load this just now
+
+ The agent is reachable but this view hit an error. It'll retry on its own.
+
+ {onRetry ? (
+
+
+ Try again
+
+ ) : null}
+
+ );
+}
+
+/** Shared date helper re-export so screens don't import format + shared both. */
+export { formatDayMonth };
diff --git a/apps/web/src/components/amend-board-screen.tsx b/apps/web/src/components/amend-board-screen.tsx
new file mode 100644
index 0000000..88469e0
--- /dev/null
+++ b/apps/web/src/components/amend-board-screen.tsx
@@ -0,0 +1,410 @@
+/**
+ * The Ghost Board — the proactive-agent hero view, rendered inside the existing
+ * dashboard shell (shared sidebar + workspace switcher).
+ *
+ * A briefing, not an inbox: it opens with what the agent already handled, shows
+ * the needs committed to the board, then the "ghosts" still gathering proof —
+ * the decisions waiting on you. Accept is optimistic; "Not a thing" teaches a
+ * Memory rule (with an Undo). Selecting a need opens its evidence trail in place.
+ */
+import { cn } from "@amend/ui/lib/utils";
+import { Link } from "@tanstack/react-router";
+import { useState } from "react";
+
+import {
+ ActionButton,
+ AgentMark,
+ Avatar,
+ AvatarStack,
+ ChannelGlyph,
+ EmptyState,
+ ErrorState,
+ ProofLine,
+ SectionLabel,
+ SkeletonBar,
+ SourceChips,
+ StrengthMeter,
+ agentButtonClass,
+ channelMeta,
+} from "@/components/amend-agent-shared";
+import { PageHeader, PageScroll } from "@/components/amend-agent-chrome";
+import { AmendNeedDetailScreen } from "@/components/amend-need-detail-screen";
+import type { Ghost, Need, ProofStrength } from "@/lib/amend-contract";
+import { formatDayMonth, growthPhrase } from "@/lib/amend-agent-format";
+import {
+ acceptGhost,
+ keepGathering,
+ killGhost,
+ restoreGhost,
+ useAcceptedNeeds,
+ useDigestPreview,
+ useGhosts,
+} from "@/lib/mock-amend";
+import { ChevronRight, Clock, GitMerge, LayoutGrid, Plus, Radio, TrendingUp, X } from "@/lib/icons";
+import { toast } from "@/lib/toast";
+
+function ribbonFor(strength: ProofStrength) {
+ if (strength === "strong") return { label: "Ready when you are", ready: true };
+ if (strength === "building") return { label: "Building proof", ready: false };
+ return { label: "Just forming", ready: false };
+}
+
+function GhostRibbon({ strength }: { strength: ProofStrength }) {
+ const { label, ready } = ribbonFor(strength);
+ return (
+
+ {ready ? (
+
+
+
+
+ ) : null}
+ {label}
+
+ );
+}
+
+function GhostCard({ ghost, onOpen }: { ghost: Ghost; onOpen: () => void }) {
+ const ready = ghost.proof.strength === "strong";
+ const thin = ghost.proof.strength === "thin";
+ const quote = ghost.sampleQuotes[0];
+
+ function onAccept() {
+ acceptGhost(ghost.id);
+ toast.success({
+ title: "Added to the board",
+ description: `“${ghost.title}” is now a tracked need.`,
+ });
+ }
+ function onKeep() {
+ keepGathering(ghost.id);
+ toast.info({
+ title: "Keeping watch",
+ description: "The agent will resurface it as the proof grows.",
+ });
+ }
+ function onKill() {
+ killGhost(ghost.id);
+ toast.success({
+ title: "Taught Amend to skip this",
+ description: `Won't surface “${ghost.title}”-style requests. Manage it in Memory.`,
+ button: { title: "Undo", onClick: () => restoreGhost(ghost.id) },
+ });
+ }
+
+ return (
+
+
+
+ {ghost.title}
+
+
+
+
+
+
+
+
+
+
+
+
+ {growthPhrase(ghost.proof.growthPerWeek)}
+
+
+ first raised {formatDayMonth(ghost.firstSeen)}
+
+
+
+ {quote ? (
+
+
+
+
+
+ “{quote.text}”
+
+
+
+ {quote.author}
+ · {channelMeta[quote.channel].label}
+
+
+
+
+ ) : null}
+
+
+
+
+ Add to board
+
+
+
+ Keep gathering
+
+
+
+ Not a thing
+
+
+
+ {thin ? (
+
+ Too thin to add yet — the agent keeps gathering until the proof earns a spot.
+
+ ) : null}
+
+ );
+}
+
+function AcceptedRow({ need, onOpen }: { need: Need; onOpen: () => void }) {
+ const requesters = need.sampleQuotes.map((q) => q.author);
+ return (
+
+
+
+
{need.title}
+ {need.linkedShip ? (
+
+ Shipped
+
+ ) : (
+
+ In progress
+
+ )}
+
+
+
+
+ {requesters.length ? (
+
+ ) : null}
+
+
+ {need.linkedShip ? (
+
+
+ {need.linkedShip.releaseTag ?? need.linkedShip.sha}
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
+
+function BoardSkeleton() {
+ return (
+
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+ ))}
+
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+function BoardBriefing({
+ handledSilently,
+ onBoard,
+ gathering,
+ ready,
+}: {
+ handledSilently: number;
+ onBoard: number;
+ gathering: number;
+ ready: number;
+}) {
+ const cells: { label: string; value: number; accent?: boolean }[] = [
+ { label: "Handled silently", value: handledSilently },
+ { label: "On the board", value: onBoard },
+ { label: "Gathering", value: gathering },
+ { label: "Ready", value: ready, accent: ready > 0 },
+ ];
+ return (
+
+
+
+
+ Handled {handledSilently} {" "}
+ signals on its own this week.{" "}
+ {gathering} {" "}
+ {gathering === 1 ? "need is" : "needs are"} still gathering proof
+ {ready > 0 ? (
+ <>
+ {" — "}
+ {ready} ready when you are .
+ >
+ ) : (
+ "."
+ )}
+
+
+
+ {cells.map((c) => (
+
+
+ {c.value}
+
+ {c.label}
+
+ ))}
+
+
+ );
+}
+
+export function AmendBoardScreen() {
+ const [selectedNeedId, setSelectedNeedId] = useState(null);
+ const ghostsQuery = useGhosts();
+ const acceptedQuery = useAcceptedNeeds();
+ const digestQuery = useDigestPreview();
+
+ if (selectedNeedId) {
+ return setSelectedNeedId(null)} />;
+ }
+
+ const isLoading = ghostsQuery.isLoading || acceptedQuery.isLoading || digestQuery.isLoading;
+ const isError = ghostsQuery.isError || acceptedQuery.isError || digestQuery.isError;
+
+ const ghosts = ghostsQuery.data ?? [];
+ const accepted = acceptedQuery.data ?? [];
+ const ready = ghosts.filter((g) => g.proof.strength === "strong").length;
+
+ return (
+ <>
+
+
+ {isError ? (
+
+ ) : isLoading ? (
+
+ ) : ghosts.length === 0 && accepted.length === 0 ? (
+
+ Connect a source
+
+ }
+ />
+ ) : (
+ <>
+
+
+
+ On the board
+ {accepted.length ? (
+
+ {accepted.map((need) => (
+
setSelectedNeedId(need.id)}
+ />
+ ))}
+
+ ) : (
+
+ Nothing committed yet. Add a ghost below when its proof is strong enough.
+
+ )}
+
+
+
+
+ Gathering proof
+ Sorted by strength
+
+ {ghosts.length ? (
+
+ {ghosts.map((ghost) => (
+ setSelectedNeedId(ghost.id)}
+ />
+ ))}
+
+ ) : (
+
+ No ghosts gathering right now — the agent is watching your sources.
+
+ )}
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/amend-dashboard-constants.ts b/apps/web/src/components/amend-dashboard-constants.ts
index 1144d8f..69696c4 100644
--- a/apps/web/src/components/amend-dashboard-constants.ts
+++ b/apps/web/src/components/amend-dashboard-constants.ts
@@ -6,7 +6,16 @@ import type {
Workspace,
} from "@/components/amend-dashboard-types";
-export const viewValues: DashboardView[] = ["posts", "roadmap", "changelog", "settings", "setup"];
+export const viewValues: DashboardView[] = [
+ "posts",
+ "roadmap",
+ "changelog",
+ "board",
+ "drafts",
+ "memory",
+ "settings",
+ "setup",
+];
export const boardValues: BoardId[] = ["feature", "bug", "changelog", "feedback"];
export const statusValues: Array = [
"all",
diff --git a/apps/web/src/components/amend-dashboard-core-types.ts b/apps/web/src/components/amend-dashboard-core-types.ts
index 653caf4..6974b9a 100644
--- a/apps/web/src/components/amend-dashboard-core-types.ts
+++ b/apps/web/src/components/amend-dashboard-core-types.ts
@@ -2,7 +2,15 @@ import type { ReactElement } from "react";
export type RoadmapStatus = "backlog" | "next" | "progress" | "done";
export type ChangelogStatusFilter = "all" | "draft" | "in_review" | "scheduled" | "published";
-export type DashboardView = "posts" | "roadmap" | "changelog" | "settings" | "setup";
+export type DashboardView =
+ | "posts"
+ | "roadmap"
+ | "changelog"
+ | "board"
+ | "drafts"
+ | "memory"
+ | "settings"
+ | "setup";
export type SettingsSection = "accounts" | "automation" | "general" | "portal" | "services";
export type BoardId = "feature" | "bug" | "changelog" | "feedback";
export type WorkspaceId = string;
diff --git a/apps/web/src/components/amend-dashboard-main-workspace.tsx b/apps/web/src/components/amend-dashboard-main-workspace.tsx
index 4760822..456bc09 100644
--- a/apps/web/src/components/amend-dashboard-main-workspace.tsx
+++ b/apps/web/src/components/amend-dashboard-main-workspace.tsx
@@ -4,6 +4,9 @@ import type {
DashboardView,
Post,
} from "@/components/amend-dashboard-types";
+import { AmendBoardScreen } from "@/components/amend-board-screen";
+import { AmendDraftsScreen } from "@/components/amend-drafts-screen";
+import { AmendMemoryScreen } from "@/components/amend-memory-screen";
import {
ChangelogWorkspace,
PostsWorkspace,
@@ -58,6 +61,12 @@ export function AmendDashboardMainWorkspace({
onVoteRoadmapItem,
roadmapViews,
}: DashboardContentProps) {
+ // The proactive-agent views are self-contained (their own header + scroll) and
+ // render on the mock layer, so they bypass the CRUD DashboardHeader entirely.
+ if (activeView === "board") return ;
+ if (activeView === "drafts") return ;
+ if (activeView === "memory") return ;
+
const onboarding = computeOnboardingState({ activeProject, dashboard, workspace });
const showOnboarding =
Boolean(dashboard) && activeProject.id !== "new-project" && ONBOARDING_VIEWS.has(activeView);
diff --git a/apps/web/src/components/amend-dashboard-status-utils.ts b/apps/web/src/components/amend-dashboard-status-utils.ts
index f31f514..4055d12 100644
--- a/apps/web/src/components/amend-dashboard-status-utils.ts
+++ b/apps/web/src/components/amend-dashboard-status-utils.ts
@@ -80,7 +80,7 @@ export function composerStatusToRoadmapStatus(status: ComposerSubmitPayload["sta
export function normalizeView(value?: string): DashboardView {
if (value === "members") return "settings";
- if (value === "share" || value === "board") return "posts";
+ if (value === "share") return "posts";
return viewValues.includes(value as DashboardView) ? (value as DashboardView) : "posts";
}
@@ -118,6 +118,9 @@ export function viewTitle(view: DashboardView) {
posts: "Feedback",
roadmap: "Roadmap",
changelog: "Changelog",
+ board: "The board",
+ drafts: "Drafts",
+ memory: "Memory",
settings: "Settings",
setup: "Create project",
};
diff --git a/apps/web/src/components/amend-drafts-screen.tsx b/apps/web/src/components/amend-drafts-screen.tsx
new file mode 100644
index 0000000..0322ae5
--- /dev/null
+++ b/apps/web/src/components/amend-drafts-screen.tsx
@@ -0,0 +1,219 @@
+/**
+ * PHASE 3 — Draft-review lane (the trust surface).
+ *
+ * The agent writes changelog entries and notify-the-requesters messages, then
+ * holds them here. Nothing sends until you approve. Every draft is editable in
+ * place (Edit + approve), and the pre-send hold is stated plainly up top.
+ */
+import { Link } from "@tanstack/react-router";
+import { useState } from "react";
+
+import {
+ ActionButton,
+ Avatar,
+ ChannelGlyph,
+ EmptyState,
+ ErrorState,
+ SectionLabel,
+ SkeletonBar,
+ channelMeta,
+} from "@/components/amend-agent-shared";
+import { PageHeader, PageScroll } from "@/components/amend-agent-chrome";
+import type { DraftProposal } from "@/lib/amend-contract";
+import { approveDraft, rejectDraft, updateDraftText, usePendingDrafts } from "@/lib/mock-amend";
+import { Check, Inbox, Megaphone, Newspaper, ShieldCheck, SquarePen, X } from "@/lib/icons";
+import { toast } from "@/lib/toast";
+
+const kindMeta = {
+ changelog: { label: "Changelog entry", Icon: Newspaper, verb: "added to your changelog" },
+ notify: { label: "Notify requesters", Icon: Megaphone, verb: "sent to the people who asked" },
+} as const;
+
+function HoldNote() {
+ return (
+
+
+
+ Nothing here has gone out. The agent writes the drafts;{" "}
+ nothing sends until you approve . Edit
+ anything first — your wording wins.
+
+
+ );
+}
+
+function RecipientChips({ draft }: { draft: DraftProposal }) {
+ if (!draft.recipients?.length) return null;
+ return (
+
+
+ Will reach{" "}
+
+ {draft.recipients.length}
+ {" "}
+ {draft.recipients.length === 1 ? "person" : "people"} who asked
+
+
+ {draft.recipients.map((r) => (
+
+
+ {r.handle}
+
+
+ ))}
+
+
+ );
+}
+
+function DraftCard({ draft }: { draft: DraftProposal }) {
+ const { label, Icon, verb } = kindMeta[draft.kind];
+ const [editing, setEditing] = useState(false);
+ const [text, setText] = useState(draft.draftText);
+
+ function approve(value?: string) {
+ if (value !== undefined) updateDraftText(draft.id, value);
+ approveDraft(draft.id);
+ toast.success({
+ title: "Approved",
+ description: `Your ${draft.kind === "notify" ? "message" : "entry"} will be ${verb}.`,
+ });
+ }
+
+ function reject() {
+ rejectDraft(draft.id);
+ toast.info({ title: "Draft rejected", description: "The agent won't send this one." });
+ }
+
+ return (
+
+
+
+
+
+
+
+
{label}
+
+ for “{draft.needTitle}”
+
+
+
+
+ Held — not sent
+
+
+
+
+
+
+ {editing ? (
+ <>
+ approve(text)}>
+
+ Save & approve
+
+ {
+ setText(draft.draftText);
+ setEditing(false);
+ }}
+ >
+ Cancel
+
+ >
+ ) : (
+ <>
+ approve()}>
+
+ Approve
+
+ setEditing(true)}>
+
+ Edit
+
+
+
+ Reject
+
+ >
+ )}
+
+
+ );
+}
+
+function DraftsSkeleton() {
+ return (
+
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function AmendDraftsScreen() {
+ const { data, isLoading, isError } = usePendingDrafts();
+ const drafts = data ?? [];
+
+ return (
+ <>
+
+
+ {isError ? (
+
+ ) : isLoading ? (
+
+ ) : drafts.length === 0 ? (
+
+ ) : (
+ <>
+
+ Pending your review
+
+ {drafts.map((draft) => (
+
+ ))}
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/amend-memory-screen.tsx b/apps/web/src/components/amend-memory-screen.tsx
new file mode 100644
index 0000000..fd2d9a8
--- /dev/null
+++ b/apps/web/src/components/amend-memory-screen.tsx
@@ -0,0 +1,294 @@
+/**
+ * PHASE 5 — Memory ("what Amend learned"). Polish-critical.
+ *
+ * Every rule is a plain-language sentence the agent now follows, grouped by what
+ * it does (skips / merges / already-handled / always-surface / patterns). Each
+ * shows who taught it, when, and its blast radius ("hides ~212/mo"), with a
+ * toggle and an undo. High-blast filters get an audit nudge so a quiet rule
+ * can't silently swallow the wrong things forever.
+ */
+import { cn } from "@amend/ui/lib/utils";
+import { useState } from "react";
+
+import {
+ AgentMark,
+ EmptyState,
+ ErrorState,
+ IconButton,
+ SkeletonBar,
+} from "@/components/amend-agent-shared";
+import { PageHeader, PageScroll } from "@/components/amend-agent-chrome";
+import type { MemoryRule, MemoryRuleKind } from "@/lib/amend-contract";
+import { relativeFromNow } from "@/lib/amend-agent-format";
+import { toggleRule, undoRule, useMemoryRules } from "@/lib/mock-amend";
+import {
+ AiMagic,
+ Brain,
+ Check,
+ Copy,
+ Filter,
+ Info,
+ ShieldCheck,
+ Undo2,
+ type LucideIcon,
+} from "@/lib/icons";
+import { toast } from "@/lib/toast";
+
+const KIND_ORDER: MemoryRuleKind[] = ["noise", "dedupe", "addressed", "allowlist", "pattern"];
+
+const kindMeta: Record<
+ MemoryRuleKind,
+ { label: string; hint: string; Icon: LucideIcon; verb: string }
+> = {
+ noise: {
+ label: "Noise it skips",
+ hint: "Signals the agent stays quiet about",
+ Icon: Filter,
+ verb: "hides",
+ },
+ dedupe: {
+ label: "Things it merges",
+ hint: "Different words, one need",
+ Icon: Copy,
+ verb: "merges",
+ },
+ addressed: {
+ label: "Already handled",
+ hint: "Won't resurface what you've shipped",
+ Icon: Check,
+ verb: "hides",
+ },
+ allowlist: {
+ label: "Always surface",
+ hint: "Never filtered, even a one-liner",
+ Icon: ShieldCheck,
+ verb: "always shows",
+ },
+ pattern: {
+ label: "Patterns it watches",
+ hint: "Tags and raises priority",
+ Icon: AiMagic,
+ verb: "tags",
+ },
+};
+
+function blastPhrase(rule: MemoryRule): string {
+ return `${kindMeta[rule.kind].verb} ~${rule.blastRadius}/mo`;
+}
+
+const AUDIT_THRESHOLD = 150;
+
+function Switch({
+ checked,
+ onChange,
+ label,
+}: {
+ checked: boolean;
+ onChange: (next: boolean) => void;
+ label: string;
+}) {
+ return (
+ onChange(!checked)}
+ className={cn(
+ "relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-150 ease-linear outline-none focus-visible:ring-2 focus-visible:ring-ring/45",
+ checked ? "bg-amend-success" : "bg-white/[0.14]",
+ )}
+ >
+
+
+ );
+}
+
+function AuditNudge({ rule }: { rule: MemoryRule }) {
+ const [dismissed, setDismissed] = useState(false);
+ if (dismissed) return null;
+ return (
+
+
+
+ This rule {kindMeta[rule.kind].verb}{" "}
+
+ ~{rule.blastRadius}
+ {" "}
+ items a month — still right?
+
+
setDismissed(true)}
+ className="ml-auto shrink-0 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:text-foreground"
+ >
+ It's right
+
+
+ );
+}
+
+function RuleRow({ rule }: { rule: MemoryRule }) {
+ const needsAudit =
+ rule.enabled && rule.blastRadius >= AUDIT_THRESHOLD && rule.kind !== "allowlist";
+
+ function onToggle(next: boolean) {
+ toggleRule(rule.id, next);
+ toast.info({
+ title: next ? "Rule on" : "Rule paused",
+ description: next
+ ? "The agent will apply it again."
+ : "The agent will stop applying it until you switch it back.",
+ });
+ }
+
+ function onUndo() {
+ undoRule(rule.id);
+ toast.success({
+ title: "Unlearned",
+ description: "Amend dropped this rule and won't apply it anymore.",
+ });
+ }
+
+ return (
+
+
+
+
+
+
+ {rule.text}
+
+
+
+ Taught by {rule.taughtBy}
+
+ ·
+ {relativeFromNow(rule.taughtAt)}
+ ·
+ {blastPhrase(rule)}
+ {!rule.enabled ? (
+
+ Off
+
+ ) : null}
+
+ {needsAudit ?
: null}
+
+
+
+
+
+ );
+}
+
+function RuleGroup({ kind, rules }: { kind: MemoryRuleKind; rules: MemoryRule[] }) {
+ const { label, hint, Icon } = kindMeta[kind];
+ return (
+
+
+
+
+
+
{label}
+
+ {rules.length}
+
+
+ {hint}
+
+
+
+ {rules.map((rule) => (
+
+ ))}
+
+
+ );
+}
+
+function MemorySkeleton() {
+ return (
+
+
+ {Array.from({ length: 2 }).map((_, i) => (
+
+
+
+
+ ))}
+
+ );
+}
+
+export function AmendMemoryScreen() {
+ const { data, isLoading, isError } = useMemoryRules();
+ const rules = data ?? [];
+
+ const enabled = rules.filter((r) => r.enabled);
+ const filteredPerMonth = enabled
+ .filter((r) => r.kind === "noise" || r.kind === "dedupe" || r.kind === "addressed")
+ .reduce((sum, r) => sum + r.blastRadius, 0);
+
+ const groups = KIND_ORDER.map((kind) => ({
+ kind,
+ rules: rules.filter((r) => r.kind === kind),
+ })).filter((g) => g.rules.length > 0);
+
+ return (
+ <>
+
+
+ {isError ? (
+
+ ) : isLoading ? (
+
+ ) : rules.length === 0 ? (
+
+ ) : (
+ <>
+
+
+
+
+ Amend is quietly filtering{" "}
+ ~{filteredPerMonth} {" "}
+ items a month so the board only shows what matters — from{" "}
+ {enabled.length} {" "}
+ {enabled.length === 1 ? "rule" : "rules"} you taught it. Toggle or undo any of
+ them.
+
+
+
+
+ {groups.map((g) => (
+
+ ))}
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/amend-need-detail-screen.tsx b/apps/web/src/components/amend-need-detail-screen.tsx
new file mode 100644
index 0000000..31109a7
--- /dev/null
+++ b/apps/web/src/components/amend-need-detail-screen.tsx
@@ -0,0 +1,402 @@
+/**
+ * Need detail / evidence trail — opened in place from the board (back returns
+ * to the grid).
+ *
+ * Sources-first. The page opens with a computed synthesis ("9 people across
+ * Discord, GitHub and support — 3 paying — first raised Mar 2, active this
+ * week"), makes the *people* the spine, then groups the raw evidence by source
+ * (collapsed to 2 quotes each). The linked ship — with its SHA — closes the loop.
+ */
+import { cn } from "@amend/ui/lib/utils";
+import { Link } from "@tanstack/react-router";
+import { useState } from "react";
+
+import {
+ ActionButton,
+ Avatar,
+ ChannelGlyph,
+ ConfidenceTag,
+ EmptyState,
+ ErrorState,
+ IconButton,
+ SectionLabel,
+ ShaChip,
+ SkeletonBar,
+ StrengthMeter,
+ agentButtonClass,
+ channelMeta,
+} from "@/components/amend-agent-shared";
+import { PageScroll } from "@/components/amend-agent-chrome";
+import type { Evidence, Need, SourceChannel } from "@/lib/amend-contract";
+import { activityPhrase, formatDayMonth, formatFullDate } from "@/lib/amend-agent-format";
+import { acceptGhost, keepGathering, killGhost, restoreGhost, useNeed } from "@/lib/mock-amend";
+import {
+ ArrowLeft,
+ ChevronDown,
+ Clock,
+ ExternalLink,
+ LayoutGrid,
+ Megaphone,
+ Plus,
+ X,
+} from "@/lib/icons";
+import { toast } from "@/lib/toast";
+
+function prose(items: string[]): string {
+ if (items.length <= 1) return items[0] ?? "";
+ if (items.length === 2) return `${items[0]} and ${items[1]}`;
+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
+}
+
+function synthesis(need: Need): string {
+ const channels = prose(need.proof.sources.map((s) => channelMeta[s.channel].label));
+ const paying = need.proof.payingPeople > 0 ? `, ${need.proof.payingPeople} paying,` : "";
+ return `${need.proof.people} people across ${channels}${paying} — first raised ${formatDayMonth(
+ need.firstSeen,
+ )}, ${activityPhrase(need.lastSeen)}.`;
+}
+
+function groupEvidence(evidence: Evidence[]): { channel: SourceChannel; items: Evidence[] }[] {
+ const map = new Map();
+ for (const e of evidence) {
+ const list = map.get(e.sourceChannel) ?? [];
+ list.push(e);
+ map.set(e.sourceChannel, list);
+ }
+ return [...map.entries()]
+ .map(([channel, items]) => ({ channel, items }))
+ .sort((a, b) => b.items.length - a.items.length);
+}
+
+function EvidenceRow({ item }: { item: Evidence }) {
+ return (
+
+
+
+
+
{item.author}
+
+
+ · {item.promotedBy === "human" ? "added by you" : "promoted by the agent"}
+
+
+
+
+
+
“{item.text}”
+
+
+ );
+}
+
+function EvidenceGroup({ channel, items }: { channel: SourceChannel; items: Evidence[] }) {
+ const [expanded, setExpanded] = useState(false);
+ const shown = expanded ? items : items.slice(0, 2);
+ const rest = items.length - shown.length;
+ return (
+
+
+
+ {channelMeta[channel].label}
+
+ {items.length}
+
+
+
+ {shown.map((item) => (
+
+ ))}
+
+ {rest > 0 || expanded ? (
+ setExpanded((v) => !v)}
+ className="flex w-full items-center justify-center gap-1.5 border-t border-white/[0.05] py-2 text-[0.72rem] font-medium text-muted-foreground transition-colors hover:bg-white/[0.02] hover:text-foreground"
+ >
+
+ {expanded ? "Show less" : `Show ${rest} more`}
+
+ ) : null}
+
+ );
+}
+
+function ProofRow({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+function ProofRail({ need, onBack }: { need: Need; onBack: () => void }) {
+ const isGhost = need.status === "ghost";
+ const thin = need.proof.strength === "thin";
+
+ return (
+
+ );
+}
+
+function DetailSkeleton() {
+ return (
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function AmendNeedDetailScreen({ needId, onBack }: { needId: string; onBack: () => void }) {
+ const { data: need, isLoading, isError } = useNeed(needId);
+ const groups = need ? groupEvidence(need.evidence) : [];
+ const people = need ? Array.from(new Set(need.evidence.map((e) => e.author))) : [];
+
+ const statusLabel = need
+ ? need.status === "accepted"
+ ? need.linkedShip
+ ? "Shipped"
+ : "On the board"
+ : "Gathering proof"
+ : "";
+
+ return (
+ <>
+
+
+
+
+
+
+ Board
+
+ {need ? (
+ <>
+ /
+ {statusLabel}
+ >
+ ) : null}
+
+
+
+
+ {isError ? (
+
+ ) : isLoading ? (
+
+ ) : !need ? (
+
+ Back to the board
+
+ }
+ />
+ ) : (
+ <>
+
+ {need.title}
+
+
+ {synthesis(need)}
+
+
+ {/* People spine */}
+
+
+ Who's asking
+
+ {people.slice(0, 10).map((name) => (
+
+
+ {name}
+
+ ))}
+ {people.length > 10 ? (
+
+ +{people.length - 10} more
+
+ ) : null}
+
+
+
+
+
Evidence by source
+ {groups.map((g) => (
+
+ ))}
+ {groups.length === 0 ? (
+
+ No evidence captured yet.
+
+ ) : null}
+
+
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/apps/web/src/components/dashboard-sidebar-chrome.tsx b/apps/web/src/components/dashboard-sidebar-chrome.tsx
index 4176a27..b033804 100644
--- a/apps/web/src/components/dashboard-sidebar-chrome.tsx
+++ b/apps/web/src/components/dashboard-sidebar-chrome.tsx
@@ -1,7 +1,9 @@
import { cn } from "@amend/ui/lib/utils";
-import { Map, MessageSquareText, Newspaper, Settings } from "@/lib/icons";
+import { Brain, Inbox, LayoutGrid, Map, MessageSquareText, Newspaper, Settings } from "@/lib/icons";
import type { ReactElement, RefObject } from "react";
+import { usePendingDrafts } from "@/lib/mock-amend";
+
import type {
ChangelogStatusFilter,
DashboardChangelog,
@@ -20,11 +22,13 @@ import { WorkspaceSwitcher } from "@/components/dashboard-workspace-switcher";
function SidebarMainNavButton({
active,
+ badge,
icon,
label,
onClick,
}: {
active: boolean;
+ badge?: number;
icon: ReactElement;
label: string;
onClick: () => void;
@@ -42,6 +46,11 @@ function SidebarMainNavButton({
>
{icon}
{label}
+ {badge ? (
+
+ {badge}
+
+ ) : null}
);
}
@@ -52,6 +61,20 @@ const NAV_ITEMS: Array<[DashboardView, ReactElement, string]> = [
["changelog", , "Changelog"],
];
+const AGENT_NAV_ITEMS: Array<[DashboardView, ReactElement, string]> = [
+ ["board", , "Board"],
+ ["drafts", , "Drafts"],
+ ["memory", , "Memory"],
+];
+
+function SidebarGroupLabel({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
export function DashboardSidebarChrome({
activeChangelogCategory,
activeChangelogStatus,
@@ -107,6 +130,8 @@ export function DashboardSidebarChrome({
roadmapViews: RoadmapView[];
workspaceMenuRef: RefObject;
}) {
+ const { data: pendingDrafts } = usePendingDrafts();
+ const pendingDraftCount = pendingDrafts?.length ?? 0;
const sidebarProps = {
activeChangelogCategory,
activeChangelogStatus,
@@ -147,7 +172,7 @@ export function DashboardSidebarChrome({
{/* Main section nav */}
-
+
{NAV_ITEMS.map(([view, icon, label]) => (
onViewChange(view)}
/>
))}
+
+ Agent
+ {AGENT_NAV_ITEMS.map(([view, icon, label]) => (
+ onViewChange(view)}
+ />
+ ))}
+
+
}
diff --git a/apps/web/src/components/dashboard-view-nav.tsx b/apps/web/src/components/dashboard-view-nav.tsx
index 75c64dc..bc92694 100644
--- a/apps/web/src/components/dashboard-view-nav.tsx
+++ b/apps/web/src/components/dashboard-view-nav.tsx
@@ -1,5 +1,14 @@
import { cn } from "@amend/ui/lib/utils";
-import { GitPullRequestArrow, Map, MessageSquareText, Newspaper, Settings } from "@/lib/icons";
+import {
+ Brain,
+ GitPullRequestArrow,
+ Inbox,
+ LayoutGrid,
+ Map,
+ MessageSquareText,
+ Newspaper,
+ Settings,
+} from "@/lib/icons";
import type { ReactElement } from "react";
import type { DashboardView } from "@/components/amend-dashboard-types";
@@ -62,6 +71,9 @@ export function MobileViewNav({
["posts", , "Feedback"],
["roadmap", , "Roadmap"],
["changelog", , "Changelog"],
+ ["board", , "Board"],
+ ["drafts", , "Drafts"],
+ ["memory", , "Memory"],
["settings", , "Settings"],
["setup", , "Setup"],
];
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 168a743..0ee1c32 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -311,6 +311,22 @@
}
}
+/* Proactive-agent console: one restrained route-enter so a navigation reads as
+ a view change. Reduced motion is handled by the global block below. */
+.amend-page-enter {
+ animation: amend-page-enter 0.22s var(--amend-ease) both;
+}
+@keyframes amend-page-enter {
+ from {
+ opacity: 0;
+ transform: translate3d(0, 6px, 0);
+ }
+ to {
+ opacity: 1;
+ transform: translate3d(0, 0, 0);
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
::before,
@@ -325,7 +341,8 @@
.amend-flowline,
.amend-flowline-v,
.amend-loop-dot,
- .amend-marquee-track {
+ .amend-marquee-track,
+ .amend-page-enter {
animation: none !important;
}
diff --git a/apps/web/src/lib/amend-agent-format.ts b/apps/web/src/lib/amend-agent-format.ts
new file mode 100644
index 0000000..2830f5d
--- /dev/null
+++ b/apps/web/src/lib/amend-agent-format.ts
@@ -0,0 +1,92 @@
+/**
+ * Formatting helpers for the proactive-agent console.
+ *
+ * All date formatting is pinned to UTC and a fixed `AMEND_NOW` reference so the
+ * server render and the client hydration produce byte-identical strings (no
+ * hydration mismatch). When the screens swap from the mock layer to live Convex
+ * data, replace `AMEND_NOW` usage with `Date.now()` inside `relativeFromNow`.
+ */
+import type { Proof, ProofStrength } from "@/lib/amend-contract";
+
+/** Fixed "now" for deterministic relative time across SSR + the mock fixtures. */
+export const AMEND_NOW = Date.UTC(2026, 5, 16, 15, 0, 0); // 2026-06-16T15:00:00Z
+
+const dayMonth = new Intl.DateTimeFormat("en-US", {
+ day: "numeric",
+ month: "short",
+ timeZone: "UTC",
+});
+const monthYear = new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ year: "numeric",
+ timeZone: "UTC",
+});
+const fullDate = new Intl.DateTimeFormat("en-US", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "UTC",
+});
+
+/** "Mar 2" */
+export function formatDayMonth(ts: number): string {
+ return dayMonth.format(ts);
+}
+
+/** "Mar 2026" */
+export function formatMonthYear(ts: number): string {
+ return monthYear.format(ts);
+}
+
+/** "March 2, 2026" */
+export function formatFullDate(ts: number): string {
+ return fullDate.format(ts);
+}
+
+const MINUTE = 60_000;
+const HOUR = 60 * MINUTE;
+const DAY = 24 * HOUR;
+const WEEK = 7 * DAY;
+
+/** "just now" / "5h ago" / "3d ago" / "2w ago" / "Mar 2" — deterministic vs AMEND_NOW. */
+export function relativeFromNow(ts: number, now: number = AMEND_NOW): string {
+ const diff = now - ts;
+ if (diff < MINUTE) return "just now";
+ if (diff < HOUR) return `${Math.floor(diff / MINUTE)}m ago`;
+ if (diff < DAY) return `${Math.floor(diff / HOUR)}h ago`;
+ if (diff < WEEK) return `${Math.floor(diff / DAY)}d ago`;
+ if (diff < 5 * WEEK) return `${Math.floor(diff / WEEK)}w ago`;
+ return formatDayMonth(ts);
+}
+
+/** "active this week" when fresh, else "last active Mar 2". */
+export function activityPhrase(lastSeen: number, now: number = AMEND_NOW): string {
+ return now - lastSeen < WEEK ? "active this week" : `last active ${formatDayMonth(lastSeen)}`;
+}
+
+export function strengthLabel(strength: ProofStrength): string {
+ return strength === "thin" ? "Thin" : strength === "building" ? "Building" : "Strong";
+}
+
+/** Filled segments out of 3 — drives the strength bar (never a percentage). */
+export function strengthSegments(strength: ProofStrength): number {
+ return strength === "thin" ? 1 : strength === "building" ? 2 : 3;
+}
+
+export function pluralize(n: number, singular: string, plural = `${singular}s`): string {
+ return `${n} ${n === 1 ? singular : plural}`;
+}
+
+/** "12 people · 3 paying · 3 sources" — the plain because-line (no decimals, ever). */
+export function proofBecauseLine(proof: Proof): string {
+ const parts = [pluralize(proof.people, "person", "people")];
+ if (proof.payingPeople > 0) parts.push(`${proof.payingPeople} paying`);
+ parts.push(pluralize(proof.sources.length, "source"));
+ return parts.join(" · ");
+}
+
+/** "4 new this week" / "steady" growth phrasing. */
+export function growthPhrase(growthPerWeek: number): string {
+ if (growthPerWeek <= 0) return "steady this week";
+ return `${growthPerWeek} new this week`;
+}
diff --git a/apps/web/src/lib/amend-contract.ts b/apps/web/src/lib/amend-contract.ts
new file mode 100644
index 0000000..988eb94
--- /dev/null
+++ b/apps/web/src/lib/amend-contract.ts
@@ -0,0 +1,143 @@
+/**
+ * AMEND — PROACTIVE AGENT · CONTRACT (the typed seam)
+ *
+ * These interfaces are copied verbatim from the CONTRACT block that lives in
+ * BOTH FRONTEND_PLAN.txt and BACKEND_PLAN.txt. They are the single source the
+ * components render against — the only shared shape between the UI and the
+ * Convex API. Do NOT add fields the CONTRACT doesn't define without updating
+ * BACKEND_PLAN.txt in the same commit (FRONTEND_PLAN rule 4).
+ *
+ * Timestamps are epoch milliseconds (number). Ids are opaque strings.
+ */
+
+export type SourceChannel = "discord" | "support" | "github" | "embed";
+
+export type GhostStatus = "ghost" | "accepted" | "killed";
+
+/** Render as a label + bar — never as a number/percent (FRONTEND_PLAN posture). */
+export type ProofStrength = "thin" | "building" | "strong";
+
+export interface ProofSource {
+ channel: SourceChannel;
+ count: number;
+}
+
+export interface Proof {
+ people: number;
+ payingPeople: number;
+ sources: ProofSource[];
+ strength: ProofStrength;
+ growthPerWeek: number;
+}
+
+export interface SampleQuote {
+ text: string;
+ author: string;
+ channel: SourceChannel;
+ url: string;
+}
+
+export interface Ghost {
+ id: string;
+ workspaceId: string;
+ title: string;
+ status: GhostStatus;
+ proof: Proof;
+ /** Max 3. */
+ sampleQuotes: SampleQuote[];
+ firstSeen: number;
+ lastSeen: number;
+}
+
+export type ConfidenceBucket = "clear" | "worth-a-look" | "unsure";
+
+export type PromotedBy = "agent" | "human";
+
+export interface Evidence {
+ id: string;
+ sourceChannel: SourceChannel;
+ author: string;
+ text: string;
+ url: string;
+ confidenceBucket: ConfidenceBucket;
+ promotedBy: PromotedBy;
+}
+
+export interface ShipLink {
+ prNumber: number;
+ sha: string;
+ releaseTag?: string;
+ mergedAt: number;
+ url: string;
+}
+
+export type Need = Ghost & {
+ evidence: Evidence[];
+ linkedShip?: ShipLink;
+};
+
+export type DraftKind = "changelog" | "notify";
+
+export type DraftStatus = "pending" | "approved" | "rejected";
+
+export interface DraftRecipient {
+ handle: string;
+ channel: SourceChannel;
+}
+
+export interface DraftProposal {
+ id: string;
+ kind: DraftKind;
+ needId: string;
+ needTitle: string;
+ draftText: string;
+ recipients?: DraftRecipient[];
+ status: DraftStatus;
+}
+
+export interface ChangelogEntry {
+ id: string;
+ title: string;
+ body: string;
+ shippedAt: number;
+ ship: ShipLink;
+ published: boolean;
+}
+
+export type MemoryRuleKind = "noise" | "dedupe" | "addressed" | "allowlist" | "pattern";
+
+export interface MemoryRule {
+ id: string;
+ kind: MemoryRuleKind;
+ text: string;
+ taughtBy: string;
+ taughtAt: number;
+ /** How many items this rule affects per month, e.g. "hides ~212/mo". */
+ blastRadius: number;
+ enabled: boolean;
+}
+
+export interface DigestResolved {
+ needTitle: string;
+ ship: ShipLink;
+ peopleNotified: number;
+}
+
+export interface DigestPreview {
+ period: { from: number; to: number };
+ resolved: DigestResolved[];
+ readyGhosts: Ghost[];
+ handledSilently: number;
+}
+
+export interface SourcesStatus {
+ github: {
+ connected: boolean;
+ repo?: string;
+ lastSync?: number;
+ };
+ feedback: {
+ connected: boolean;
+ channels: { channel: SourceChannel; connected: boolean; lastSignal?: number }[];
+ };
+}
diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx
index 8e8de3f..c5932cf 100644
--- a/apps/web/src/lib/icons.tsx
+++ b/apps/web/src/lib/icons.tsx
@@ -8,6 +8,8 @@
*/
import {
Add01Icon,
+ AiMagicIcon,
+ Alert02Icon,
ArrowDown01Icon,
ArrowLeft01Icon,
ArrowRight01Icon,
@@ -16,29 +18,40 @@ import {
Attachment01Icon,
BookOpen01Icon,
BotIcon,
+ BrainIcon,
Calendar01Icon,
Calendar03Icon,
Cancel01Icon,
+ ChartUpIcon,
CheckmarkCircle02Icon,
CircleIcon,
Clock01Icon,
Copy01Icon,
DashedLineCircleIcon,
Database01Icon,
+ DiscordIcon,
Download04Icon,
File01Icon,
+ FilterHorizontalIcon,
Folder01Icon,
FolderAddIcon,
FolderOpenIcon,
+ GitCommitIcon,
+ GitMergeIcon,
GitPullRequestIcon,
+ Github01Icon,
GlobalIcon,
Heading1Icon,
Heading2Icon,
Idea01Icon,
Image02Icon,
InboxIcon,
+ InformationCircleIcon,
+ KanbanIcon,
Key01Icon,
+ LayoutGridIcon,
LeftToRightListNumberIcon,
+ LifebuoyIcon,
Link01Icon,
LinkSquare02Icon,
ListViewIcon,
@@ -53,16 +66,19 @@ import {
MoreHorizontalIcon,
News01Icon,
Notification01Icon,
+ PencilEdit02Icon,
PlusSignIcon,
QuoteDownIcon,
Radio01Icon,
Road01Icon,
Route01Icon,
Search01Icon,
+ SecurityCheckIcon,
SentIcon,
Settings01Icon,
Settings02Icon,
SidebarRight01Icon,
+ SlidersHorizontalIcon,
SmileIcon,
SourceCodeIcon,
SparklesIcon,
@@ -74,6 +90,7 @@ import {
TextStrikethroughIcon,
TextUnderlineIcon,
Tick02Icon,
+ Undo02Icon,
UnfoldMoreIcon,
UserCircleIcon,
UserGroupIcon,
@@ -95,10 +112,13 @@ function icon(svg: IconSvgElement, displayName: string) {
return Icon;
}
+export const AiMagic = icon(AiMagicIcon, "AiMagic");
+export const AlertCircle = icon(Alert02Icon, "AlertCircle");
export const ArrowLeft = icon(ArrowLeft01Icon, "ArrowLeft");
export const ArrowRight = icon(ArrowRight01Icon, "ArrowRight");
export const ArrowUpRight = icon(ArrowUpRight01Icon, "ArrowUpRight");
export const Bell = icon(Notification01Icon, "Bell");
+export const Brain = icon(BrainIcon, "Brain");
export const Bold = icon(TextBoldIcon, "Bold");
export const BookOpen = icon(BookOpen01Icon, "BookOpen");
export const Bot = icon(BotIcon, "Bot");
@@ -167,3 +187,19 @@ export const Underline = icon(TextUnderlineIcon, "Underline");
export const UserRound = icon(UserCircleIcon, "UserRound");
export const Users = icon(UserGroupIcon, "Users");
export const X = icon(Cancel01Icon, "X");
+
+// — Proactive-agent console additions —
+export const Discord = icon(DiscordIcon, "Discord");
+export const Filter = icon(FilterHorizontalIcon, "Filter");
+export const GitCommit = icon(GitCommitIcon, "GitCommit");
+export const GitMerge = icon(GitMergeIcon, "GitMerge");
+export const Github = icon(Github01Icon, "Github");
+export const Info = icon(InformationCircleIcon, "Info");
+export const Kanban = icon(KanbanIcon, "Kanban");
+export const LayoutGrid = icon(LayoutGridIcon, "LayoutGrid");
+export const LifeBuoy = icon(LifebuoyIcon, "LifeBuoy");
+export const ShieldCheck = icon(SecurityCheckIcon, "ShieldCheck");
+export const SlidersHorizontal = icon(SlidersHorizontalIcon, "SlidersHorizontal");
+export const SquarePen = icon(PencilEdit02Icon, "SquarePen");
+export const TrendingUp = icon(ChartUpIcon, "TrendingUp");
+export const Undo2 = icon(Undo02Icon, "Undo2");
diff --git a/apps/web/src/lib/mock-amend.ts b/apps/web/src/lib/mock-amend.ts
new file mode 100644
index 0000000..34de8c2
--- /dev/null
+++ b/apps/web/src/lib/mock-amend.ts
@@ -0,0 +1,809 @@
+/**
+ * AMEND — PROACTIVE AGENT · MOCK DATA LAYER
+ *
+ * The single swap seam between the UI and the backend (FRONTEND_PLAN Phase 0).
+ * Every screen renders against the per-screen hooks exported here. Today they
+ * read from an in-memory reactive store seeded with fixtures that match the
+ * CONTRACT shapes exactly. At integration (Phase 7) each hook body flips to
+ * `useQuery(api.x.y, args)` / `useMutation(...)` — one function body per screen,
+ * the component never changes because the shape is identical.
+ *
+ * The store is intentionally simple (module-level immutable state + listeners)
+ * so optimistic mutations re-render every screen. It is demo/mock scaffolding —
+ * it is removed when the live API lands, so cross-request server state is a
+ * non-issue here (mutations only run client-side).
+ */
+import { useSyncExternalStore } from "react";
+
+import type {
+ ChangelogEntry,
+ DraftProposal,
+ Ghost,
+ MemoryRule,
+ Need,
+ SourcesStatus,
+ DigestPreview,
+} from "@/lib/amend-contract";
+import { AMEND_NOW } from "@/lib/amend-agent-format";
+
+const WS = "ws_amend";
+const min = 60_000;
+const hour = 60 * min;
+const day = 24 * hour;
+const week = 7 * day;
+const ago = (ms: number) => AMEND_NOW - ms;
+
+// ---------------------------------------------------------------------------
+// Fixtures — a coherent week in the life of the agent.
+// Everything is modeled as a Need (Ghost + evidence + optional ship); the board
+// derives ghosts (status "ghost") and accepted needs (status "accepted").
+// ---------------------------------------------------------------------------
+
+const NEEDS: Need[] = [
+ {
+ id: "need_slack_notifs",
+ workspaceId: WS,
+ title: "Slack notifications when new feedback lands",
+ status: "ghost",
+ proof: {
+ people: 23,
+ payingPeople: 8,
+ sources: [
+ { channel: "discord", count: 11 },
+ { channel: "support", count: 7 },
+ { channel: "github", count: 3 },
+ { channel: "embed", count: 2 },
+ ],
+ strength: "strong",
+ growthPerWeek: 9,
+ },
+ firstSeen: ago(5 * week),
+ lastSeen: ago(20 * hour),
+ sampleQuotes: [
+ {
+ text: "We'd route this to our team channel instantly if you posted to Slack. Email digests get buried.",
+ author: "Priya · Northwind",
+ channel: "support",
+ url: "https://support.amend.sh/t/4821",
+ },
+ {
+ text: "+1 for Slack. Checking the portal daily is the one bit of friction left for us.",
+ author: "@marco_kp",
+ channel: "discord",
+ url: "https://discord.com/channels/amend/991",
+ },
+ {
+ text: "A simple incoming-webhook to Slack would close this loop for our PM.",
+ author: "kev-oss",
+ channel: "github",
+ url: "https://github.com/amend-sh/amend/issues/612",
+ },
+ ],
+ evidence: [
+ {
+ id: "ev_sn_1",
+ sourceChannel: "support",
+ author: "Priya · Northwind",
+ text: "We'd route this to our team channel instantly if you posted to Slack. Email digests get buried.",
+ url: "https://support.amend.sh/t/4821",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sn_2",
+ sourceChannel: "support",
+ author: "Dana · Loopwork",
+ text: "Slack is where our team lives. Right now someone has to remember to check Amend.",
+ url: "https://support.amend.sh/t/4790",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sn_3",
+ sourceChannel: "discord",
+ author: "@marco_kp",
+ text: "+1 for Slack. Checking the portal daily is the one bit of friction left for us.",
+ url: "https://discord.com/channels/amend/991",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sn_4",
+ sourceChannel: "discord",
+ author: "@lena.builds",
+ text: "Would love a Slack ping per new request — even just the title and a link.",
+ url: "https://discord.com/channels/amend/994",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sn_5",
+ sourceChannel: "github",
+ author: "kev-oss",
+ text: "A simple incoming-webhook to Slack would close this loop for our PM.",
+ url: "https://github.com/amend-sh/amend/issues/612",
+ confidenceBucket: "clear",
+ promotedBy: "human",
+ },
+ {
+ id: "ev_sn_6",
+ sourceChannel: "embed",
+ author: "anon · acme.com",
+ text: "Can this notify Slack? We won't adopt a second inbox.",
+ url: "https://app.amend.sh/portal/acme/p/1182",
+ confidenceBucket: "unsure",
+ promotedBy: "agent",
+ },
+ ],
+ },
+ {
+ id: "need_sso_saml",
+ workspaceId: WS,
+ title: "SSO / SAML for team plans",
+ status: "ghost",
+ proof: {
+ people: 9,
+ payingPeople: 3,
+ sources: [
+ { channel: "support", count: 5 },
+ { channel: "github", count: 4 },
+ ],
+ strength: "building",
+ growthPerWeek: 4,
+ },
+ firstSeen: ago(3 * week),
+ lastSeen: ago(2 * day),
+ sampleQuotes: [
+ {
+ text: "Our security review blocks rollout without SAML SSO. Happy to pilot Okta with you.",
+ author: "Sam · Vantage (Enterprise)",
+ channel: "support",
+ url: "https://support.amend.sh/t/5012",
+ },
+ {
+ text: "SCIM would be a bonus but SAML is the hard requirement for us.",
+ author: "ops-grendel",
+ channel: "github",
+ url: "https://github.com/amend-sh/amend/issues/640",
+ },
+ ],
+ evidence: [
+ {
+ id: "ev_sso_1",
+ sourceChannel: "support",
+ author: "Sam · Vantage",
+ text: "Our security review blocks rollout without SAML SSO. Happy to pilot Okta with you.",
+ url: "https://support.amend.sh/t/5012",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sso_2",
+ sourceChannel: "support",
+ author: "Rae · Foundry",
+ text: "We can't expand seats past 10 until there's SSO — IT won't sign off.",
+ url: "https://support.amend.sh/t/4998",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_sso_3",
+ sourceChannel: "github",
+ author: "ops-grendel",
+ text: "SCIM would be a bonus but SAML is the hard requirement for us.",
+ url: "https://github.com/amend-sh/amend/issues/640",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "human",
+ },
+ {
+ id: "ev_sso_4",
+ sourceChannel: "github",
+ author: "dleitner",
+ text: "Is SAML on the roadmap? Asking before we commit the team.",
+ url: "https://github.com/amend-sh/amend/issues/651",
+ confidenceBucket: "unsure",
+ promotedBy: "agent",
+ },
+ ],
+ },
+ {
+ id: "need_csv_export",
+ workspaceId: WS,
+ title: "Bulk CSV export of saved views",
+ status: "ghost",
+ proof: {
+ people: 4,
+ payingPeople: 0,
+ sources: [
+ { channel: "embed", count: 3 },
+ { channel: "discord", count: 1 },
+ ],
+ strength: "thin",
+ growthPerWeek: 2,
+ },
+ firstSeen: ago(4 * day),
+ lastSeen: ago(6 * hour),
+ sampleQuotes: [
+ {
+ text: "I just want to pull a saved view into a spreadsheet for our weekly review.",
+ author: "anon · portal",
+ channel: "embed",
+ url: "https://app.amend.sh/portal/acme/p/1205",
+ },
+ ],
+ evidence: [
+ {
+ id: "ev_csv_1",
+ sourceChannel: "embed",
+ author: "anon · portal",
+ text: "I just want to pull a saved view into a spreadsheet for our weekly review.",
+ url: "https://app.amend.sh/portal/acme/p/1205",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_csv_2",
+ sourceChannel: "embed",
+ author: "anon · portal",
+ text: "Export to CSV?",
+ url: "https://app.amend.sh/portal/acme/p/1209",
+ confidenceBucket: "unsure",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_csv_3",
+ sourceChannel: "discord",
+ author: "@tomas",
+ text: "Would be handy to export the roadmap as a csv for stakeholders.",
+ url: "https://discord.com/channels/amend/1020",
+ confidenceBucket: "unsure",
+ promotedBy: "agent",
+ },
+ ],
+ },
+ {
+ id: "need_webhook_retries",
+ workspaceId: WS,
+ title: "Webhook retries with backoff",
+ status: "accepted",
+ proof: {
+ people: 14,
+ payingPeople: 6,
+ sources: [
+ { channel: "github", count: 8 },
+ { channel: "support", count: 4 },
+ { channel: "discord", count: 2 },
+ ],
+ strength: "strong",
+ growthPerWeek: 3,
+ },
+ firstSeen: ago(6 * week),
+ lastSeen: ago(4 * day),
+ linkedShip: {
+ prNumber: 1184,
+ sha: "9f3c1ae",
+ releaseTag: "v2.4.0",
+ mergedAt: ago(2 * day),
+ url: "https://github.com/amend-sh/amend/pull/1184",
+ },
+ sampleQuotes: [
+ {
+ text: "When our endpoint blips we silently lose events. Retries with backoff would save us.",
+ author: "infra-noor",
+ channel: "github",
+ url: "https://github.com/amend-sh/amend/issues/588",
+ },
+ ],
+ evidence: [
+ {
+ id: "ev_wh_1",
+ sourceChannel: "github",
+ author: "infra-noor",
+ text: "When our endpoint blips we silently lose events. Retries with backoff would save us.",
+ url: "https://github.com/amend-sh/amend/issues/588",
+ confidenceBucket: "clear",
+ promotedBy: "human",
+ },
+ {
+ id: "ev_wh_2",
+ sourceChannel: "github",
+ author: "@dwsmith",
+ text: "A dead-letter + exponential backoff is table stakes for production webhooks.",
+ url: "https://github.com/amend-sh/amend/issues/590",
+ confidenceBucket: "clear",
+ promotedBy: "human",
+ },
+ {
+ id: "ev_wh_3",
+ sourceChannel: "support",
+ author: "Priya · Northwind",
+ text: "We had a 3-minute outage and lost ~40 events with no way to replay them.",
+ url: "https://support.amend.sh/t/4655",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_wh_4",
+ sourceChannel: "discord",
+ author: "@marco_kp",
+ text: "Please add retries — we built our own queue to work around this.",
+ url: "https://discord.com/channels/amend/870",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "agent",
+ },
+ ],
+ },
+ {
+ id: "need_portal_dark_mode",
+ workspaceId: WS,
+ title: "Dark mode for the public portal",
+ status: "accepted",
+ proof: {
+ people: 18,
+ payingPeople: 5,
+ sources: [
+ { channel: "discord", count: 9 },
+ { channel: "embed", count: 6 },
+ { channel: "support", count: 3 },
+ ],
+ strength: "building",
+ growthPerWeek: 5,
+ },
+ firstSeen: ago(4 * week),
+ lastSeen: ago(1 * day),
+ sampleQuotes: [
+ {
+ text: "Our docs and app are dark — the bright Amend portal is a jarring jump for users.",
+ author: "@lena.builds",
+ channel: "discord",
+ url: "https://discord.com/channels/amend/940",
+ },
+ ],
+ evidence: [
+ {
+ id: "ev_dm_1",
+ sourceChannel: "discord",
+ author: "@lena.builds",
+ text: "Our docs and app are dark — the bright Amend portal is a jarring jump for users.",
+ url: "https://discord.com/channels/amend/940",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_dm_2",
+ sourceChannel: "embed",
+ author: "anon · portal",
+ text: "Is there a dark theme? Staring at white at night is rough.",
+ url: "https://app.amend.sh/portal/acme/p/1140",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "agent",
+ },
+ {
+ id: "ev_dm_3",
+ sourceChannel: "support",
+ author: "Dana · Loopwork",
+ text: "Brand-matched dark mode for the portal would help it feel native in our product.",
+ url: "https://support.amend.sh/t/4881",
+ confidenceBucket: "worth-a-look",
+ promotedBy: "agent",
+ },
+ ],
+ },
+];
+
+const DRAFTS: DraftProposal[] = [
+ {
+ id: "draft_wh_changelog",
+ kind: "changelog",
+ needId: "need_webhook_retries",
+ needTitle: "Webhook retries with backoff",
+ status: "pending",
+ draftText:
+ "Webhooks now retry automatically with exponential backoff. If your endpoint is briefly unavailable, we'll re-attempt delivery (up to 6 times over ~1 hour) and surface failures in a new dead-letter view — so a blip no longer means lost events.",
+ },
+ {
+ id: "draft_wh_notify",
+ kind: "notify",
+ needId: "need_webhook_retries",
+ needTitle: "Webhook retries with backoff",
+ status: "pending",
+ draftText:
+ "Hey — you asked about this a few weeks back. Webhook retries with backoff just shipped in v2.4.0, including a dead-letter view to replay failed deliveries. Nothing for you to do; it's on for your workspace now. Thanks for the nudge.",
+ recipients: [
+ { handle: "infra-noor", channel: "github" },
+ { handle: "@dwsmith", channel: "github" },
+ { handle: "Priya · Northwind", channel: "support" },
+ { handle: "@marco_kp", channel: "discord" },
+ ],
+ },
+];
+
+const CHANGELOG: ChangelogEntry[] = [
+ {
+ id: "cl_webhook_retries",
+ title: "Webhook retries with backoff",
+ body: "Webhooks now retry automatically with exponential backoff, plus a dead-letter view to replay failed deliveries. A brief outage no longer means lost events.",
+ shippedAt: ago(2 * day),
+ published: false,
+ ship: {
+ prNumber: 1184,
+ sha: "9f3c1ae",
+ releaseTag: "v2.4.0",
+ mergedAt: ago(2 * day),
+ url: "https://github.com/amend-sh/amend/pull/1184",
+ },
+ },
+ {
+ id: "cl_notif_prefs",
+ title: "Granular notification preferences",
+ body: "Choose exactly which events email you and which stay in-app, per project. Mute the noise without going dark on the things you care about.",
+ shippedAt: ago(9 * day),
+ published: true,
+ ship: {
+ prNumber: 1162,
+ sha: "4ad77b2",
+ releaseTag: "v2.3.0",
+ mergedAt: ago(9 * day),
+ url: "https://github.com/amend-sh/amend/pull/1162",
+ },
+ },
+ {
+ id: "cl_portal_search",
+ title: "Faster portal search",
+ body: "Search across feedback, roadmap and changelog is now ~4× faster and ranks exact-title matches first.",
+ shippedAt: ago(18 * day),
+ published: true,
+ ship: {
+ prNumber: 1140,
+ sha: "b81e09c",
+ mergedAt: ago(18 * day),
+ url: "https://github.com/amend-sh/amend/pull/1140",
+ },
+ },
+];
+
+const MEMORY_RULES: MemoryRule[] = [
+ {
+ id: "rule_plus_one_noise",
+ kind: "noise",
+ text: "Skip bare “+1” and “me too” reactions with no detail of their own.",
+ taughtBy: "you",
+ taughtAt: ago(16 * day),
+ blastRadius: 212,
+ enabled: true,
+ },
+ {
+ id: "rule_sentry_noise",
+ kind: "noise",
+ text: "Ignore automated Sentry crash digests posted into #alerts.",
+ taughtBy: "you",
+ taughtAt: ago(23 * day),
+ blastRadius: 188,
+ enabled: true,
+ },
+ {
+ id: "rule_sso_dedupe",
+ kind: "dedupe",
+ text: "Treat “SSO”, “SAML” and “single sign-on” as one need.",
+ taughtBy: "agent",
+ taughtAt: ago(11 * day),
+ blastRadius: 34,
+ enabled: true,
+ },
+ {
+ id: "rule_csv_addressed",
+ kind: "addressed",
+ text: "“Slow CSV export” was fixed in v2.1 — stop resurfacing the old complaint.",
+ taughtBy: "you",
+ taughtAt: ago(31 * day),
+ blastRadius: 27,
+ enabled: false,
+ },
+ {
+ id: "rule_enterprise_allowlist",
+ kind: "allowlist",
+ text: "Always surface anything from paying customers in #enterprise, even a one-liner.",
+ taughtBy: "you",
+ taughtAt: ago(8 * day),
+ blastRadius: 41,
+ enabled: true,
+ },
+ {
+ id: "rule_competitor_pattern",
+ kind: "pattern",
+ text: "Tag messages that name a competitor as “switching risk” and raise their priority.",
+ taughtBy: "agent",
+ taughtAt: ago(5 * day),
+ blastRadius: 16,
+ enabled: true,
+ },
+];
+
+const SOURCES: SourcesStatus = {
+ github: {
+ connected: true,
+ repo: "amend-sh/amend",
+ lastSync: ago(12 * min),
+ },
+ feedback: {
+ connected: true,
+ channels: [
+ { channel: "discord", connected: true, lastSignal: ago(40 * min) },
+ { channel: "support", connected: true, lastSignal: ago(3 * hour) },
+ { channel: "embed", connected: true, lastSignal: ago(25 * min) },
+ { channel: "github", connected: true, lastSignal: ago(12 * min) },
+ ],
+ },
+};
+
+function buildDigest(needs: Need[]): DigestPreview {
+ const find = (id: string) => needs.find((n) => n.id === id);
+ const webhook = find("need_webhook_retries");
+ const ready = needs.filter((n) => n.status === "ghost" && n.proof.strength === "strong");
+ return {
+ period: { from: ago(week), to: AMEND_NOW },
+ resolved: [
+ webhook?.linkedShip
+ ? { needTitle: webhook.title, ship: webhook.linkedShip, peopleNotified: 6 }
+ : null,
+ {
+ needTitle: "Granular notification preferences",
+ ship: CHANGELOG[1].ship,
+ peopleNotified: 12,
+ },
+ ].filter((x): x is NonNullable => x !== null),
+ readyGhosts: ready.map(toGhost),
+ handledSilently: 38,
+ };
+}
+
+function toGhost(need: Need): Ghost {
+ // Drop the Need-only fields so listGhosts returns exactly the Ghost shape.
+ const { evidence: _evidence, linkedShip: _linkedShip, ...ghost } = need;
+ void _evidence;
+ void _linkedShip;
+ return ghost;
+}
+
+// ---------------------------------------------------------------------------
+// Reactive store
+// ---------------------------------------------------------------------------
+
+export type MockOverride = null | "loading" | "empty" | "error";
+
+interface AmendState {
+ needs: Need[];
+ drafts: DraftProposal[];
+ changelog: ChangelogEntry[];
+ rules: MemoryRule[];
+ sources: SourcesStatus;
+ override: MockOverride;
+}
+
+let state: AmendState = {
+ needs: NEEDS,
+ drafts: DRAFTS,
+ changelog: CHANGELOG,
+ rules: MEMORY_RULES,
+ sources: SOURCES,
+ override: null,
+};
+
+const listeners = new Set<() => void>();
+
+function emit() {
+ for (const l of listeners) l();
+}
+
+function setState(next: Partial) {
+ state = { ...state, ...next };
+ emit();
+}
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+function getSnapshot() {
+ return state;
+}
+
+function useAmendState(): AmendState {
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+}
+
+/** Review affordance: force every list into a loading/empty/error state. */
+export function setMockOverride(override: MockOverride) {
+ if (state.override !== override) setState({ override });
+}
+
+// ---------------------------------------------------------------------------
+// Query result envelope (mirrors what the swap to @convex-dev/react-query gives)
+// ---------------------------------------------------------------------------
+
+export interface QueryResult {
+ data: T | undefined;
+ isLoading: boolean;
+ isError: boolean;
+}
+
+function resolve(override: MockOverride, getData: () => T, emptyValue: T): QueryResult {
+ if (override === "loading") return { data: undefined, isLoading: true, isError: false };
+ if (override === "error") return { data: undefined, isLoading: false, isError: true };
+ if (override === "empty") return { data: emptyValue, isLoading: false, isError: false };
+ return { data: getData(), isLoading: false, isError: false };
+}
+
+const strengthRank = { strong: 0, building: 1, thin: 2 } as const;
+
+// ---------------------------------------------------------------------------
+// Per-screen query hooks — flip ONE body each to useQuery(api.x.y) at Phase 7.
+// ---------------------------------------------------------------------------
+
+/** SWAP: useQuery(api.needs.listGhosts, {}) */
+export function useGhosts(): QueryResult {
+ const s = useAmendState();
+ return resolve(
+ s.override,
+ () =>
+ s.needs
+ .filter((n) => n.status === "ghost")
+ .sort(
+ (a, b) =>
+ strengthRank[a.proof.strength] - strengthRank[b.proof.strength] ||
+ b.proof.people - a.proof.people,
+ )
+ .map(toGhost),
+ [],
+ );
+}
+
+/** SWAP: useQuery(api.needs.listAccepted, {}) */
+export function useAcceptedNeeds(): QueryResult {
+ const s = useAmendState();
+ return resolve(
+ s.override,
+ () => s.needs.filter((n) => n.status === "accepted").sort((a, b) => b.lastSeen - a.lastSeen),
+ [],
+ );
+}
+
+/** SWAP: useQuery(api.needs.get, { needId }) */
+export function useNeed(needId: string): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => s.needs.find((n) => n.id === needId) ?? null, null);
+}
+
+/** SWAP: useQuery(api.drafts.listPending, {}) */
+export function usePendingDrafts(): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => s.drafts.filter((d) => d.status === "pending"), []);
+}
+
+/** SWAP: useQuery(api.changelog.list, {}) */
+export function useChangelog(): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => [...s.changelog].sort((a, b) => b.shippedAt - a.shippedAt), []);
+}
+
+/** SWAP: useQuery(api.memory.listRules, {}) */
+export function useMemoryRules(): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => s.rules, []);
+}
+
+/** SWAP: useQuery(api.digest.preview, {}) */
+export function useDigestPreview(): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => buildDigest(s.needs), {
+ period: { from: ago(week), to: AMEND_NOW },
+ resolved: [],
+ readyGhosts: [],
+ handledSilently: 0,
+ });
+}
+
+/** SWAP: useQuery(api.sources.status, {}) */
+export function useSourcesStatus(): QueryResult {
+ const s = useAmendState();
+ return resolve(s.override, () => s.sources, s.sources);
+}
+
+// ---------------------------------------------------------------------------
+// Mutations — flip each to useMutation(api.x.y) at Phase 7. They mutate the
+// store optimistically (and immediately, since there's no network here).
+// ---------------------------------------------------------------------------
+
+function patchNeed(id: string, patch: Partial) {
+ setState({ needs: state.needs.map((n) => (n.id === id ? { ...n, ...patch } : n)) });
+}
+
+export function acceptGhost(ghostId: string) {
+ patchNeed(ghostId, { status: "accepted" });
+}
+
+export function keepGathering(_ghostId: string) {
+ // Acknowledge-and-watch: no data change, the agent keeps gathering proof.
+ void _ghostId;
+}
+
+export function killGhost(ghostId: string, reason?: string) {
+ const need = state.needs.find((n) => n.id === ghostId);
+ patchNeed(ghostId, { status: "killed" });
+ // Killing teaches a noise rule — the teaching receipt is visible in Memory,
+ // where it can be undone. This wires the kill -> memory loop from the plan.
+ if (need) {
+ const rule: MemoryRule = {
+ id: `rule_taught_${ghostId}`,
+ kind: "noise",
+ text: reason?.trim()
+ ? `Stop surfacing “${need.title}” — ${reason.trim()}.`
+ : `Stop surfacing requests like “${need.title}”.`,
+ taughtBy: "you",
+ taughtAt: AMEND_NOW,
+ blastRadius: Math.max(3, need.proof.people),
+ enabled: true,
+ };
+ setState({ rules: [rule, ...state.rules.filter((r) => r.id !== rule.id)] });
+ }
+}
+
+/** Restore a killed ghost (paired with the kill teaching-receipt undo). */
+export function restoreGhost(ghostId: string) {
+ patchNeed(ghostId, { status: "ghost" });
+ setState({ rules: state.rules.filter((r) => r.id !== `rule_taught_${ghostId}`) });
+}
+
+export function approveDraft(draftId: string) {
+ setState({
+ drafts: state.drafts.map((d) => (d.id === draftId ? { ...d, status: "approved" } : d)),
+ });
+}
+
+export function rejectDraft(draftId: string, edits?: string) {
+ setState({
+ drafts: state.drafts.map((d) =>
+ d.id === draftId
+ ? { ...d, status: "rejected", draftText: edits?.trim() ? edits : d.draftText }
+ : d,
+ ),
+ });
+}
+
+/** Edit a pending draft in place (Edit + approve flow). */
+export function updateDraftText(draftId: string, draftText: string) {
+ setState({
+ drafts: state.drafts.map((d) => (d.id === draftId ? { ...d, draftText } : d)),
+ });
+}
+
+export function toggleRule(ruleId: string, enabled: boolean) {
+ setState({ rules: state.rules.map((r) => (r.id === ruleId ? { ...r, enabled } : r)) });
+}
+
+export function undoRule(ruleId: string) {
+ setState({ rules: state.rules.filter((r) => r.id !== ruleId) });
+}
+
+export function publishChangelog(entryId: string) {
+ setState({
+ changelog: state.changelog.map((e) => (e.id === entryId ? { ...e, published: true } : e)),
+ });
+}
+
+export function unpublishChangelog(entryId: string) {
+ setState({
+ changelog: state.changelog.map((e) => (e.id === entryId ? { ...e, published: false } : e)),
+ });
+}
+
+export function connectGithub(repo: string, _token: string) {
+ void _token;
+ setState({
+ sources: {
+ ...state.sources,
+ github: { connected: true, repo, lastSync: AMEND_NOW },
+ },
+ });
+}
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index d0f8b90..e40a8cf 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -8,6 +8,7 @@
* @module
*/
+import type * as agent from "../agent.js";
import type * as amend from "../amend.js";
import type * as amendAccessFunctionArgs from "../amendAccessFunctionArgs.js";
import type * as amendAccessMutationHandlers from "../amendAccessMutationHandlers.js";
@@ -112,6 +113,11 @@ import type * as amendWorkspaceRecordNormalizers from "../amendWorkspaceRecordNo
import type * as amendWorkspaceSettingsMutationHandlers from "../amendWorkspaceSettingsMutationHandlers.js";
import type * as amendWorkspaceSettingsReadHandlers from "../amendWorkspaceSettingsReadHandlers.js";
import type * as auth from "../auth.js";
+import type * as backfill from "../backfill.js";
+import type * as changelog from "../changelog.js";
+import type * as crons from "../crons.js";
+import type * as digest from "../digest.js";
+import type * as drafts from "../drafts.js";
import type * as healthCheck from "../healthCheck.js";
import type * as http from "../http.js";
import type * as httpRestGet from "../httpRestGet.js";
@@ -135,13 +141,23 @@ import type * as httpRuntimeScalars from "../httpRuntimeScalars.js";
import type * as httpRuntimeSourceEventInputs from "../httpRuntimeSourceEventInputs.js";
import type * as httpRuntimeSourceEventTypes from "../httpRuntimeSourceEventTypes.js";
import type * as httpRuntimeStripe from "../httpRuntimeStripe.js";
+import type * as ingest from "../ingest.js";
+import type * as memory from "../memory.js";
+import type * as needs from "../needs.js";
+import type * as pipeline from "../pipeline.js";
import type * as posthog from "../posthog.js";
import type * as privateData from "../privateData.js";
+import type * as proactiveArgs from "../proactiveArgs.js";
+import type * as proactiveClassifier from "../proactiveClassifier.js";
+import type * as proactiveProof from "../proactiveProof.js";
+import type * as proactiveShared from "../proactiveShared.js";
+import type * as proactiveValidators from "../proactiveValidators.js";
import type * as projectWebsiteEnrichment from "../projectWebsiteEnrichment.js";
import type * as projectWebsiteMetadata from "../projectWebsiteMetadata.js";
import type * as projectWebsiteSuggestions from "../projectWebsiteSuggestions.js";
import type * as projectWebsiteUrl from "../projectWebsiteUrl.js";
import type * as projects from "../projects.js";
+import type * as schemaProactiveTables from "../schemaProactiveTables.js";
import type * as schemaProductCommon from "../schemaProductCommon.js";
import type * as schemaProductContentTables from "../schemaProductContentTables.js";
import type * as schemaProductIdentityTables from "../schemaProductIdentityTables.js";
@@ -155,6 +171,7 @@ import type * as schemaWorkspaceIntegrationTables from "../schemaWorkspaceIntegr
import type * as schemaWorkspaceSourceTables from "../schemaWorkspaceSourceTables.js";
import type * as schemaWorkspaceTables from "../schemaWorkspaceTables.js";
import type * as signatures from "../signatures.js";
+import type * as sources from "../sources.js";
import type {
ApiFromModules,
@@ -163,6 +180,7 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
+ agent: typeof agent;
amend: typeof amend;
amendAccessFunctionArgs: typeof amendAccessFunctionArgs;
amendAccessMutationHandlers: typeof amendAccessMutationHandlers;
@@ -267,6 +285,11 @@ declare const fullApi: ApiFromModules<{
amendWorkspaceSettingsMutationHandlers: typeof amendWorkspaceSettingsMutationHandlers;
amendWorkspaceSettingsReadHandlers: typeof amendWorkspaceSettingsReadHandlers;
auth: typeof auth;
+ backfill: typeof backfill;
+ changelog: typeof changelog;
+ crons: typeof crons;
+ digest: typeof digest;
+ drafts: typeof drafts;
healthCheck: typeof healthCheck;
http: typeof http;
httpRestGet: typeof httpRestGet;
@@ -290,13 +313,23 @@ declare const fullApi: ApiFromModules<{
httpRuntimeSourceEventInputs: typeof httpRuntimeSourceEventInputs;
httpRuntimeSourceEventTypes: typeof httpRuntimeSourceEventTypes;
httpRuntimeStripe: typeof httpRuntimeStripe;
+ ingest: typeof ingest;
+ memory: typeof memory;
+ needs: typeof needs;
+ pipeline: typeof pipeline;
posthog: typeof posthog;
privateData: typeof privateData;
+ proactiveArgs: typeof proactiveArgs;
+ proactiveClassifier: typeof proactiveClassifier;
+ proactiveProof: typeof proactiveProof;
+ proactiveShared: typeof proactiveShared;
+ proactiveValidators: typeof proactiveValidators;
projectWebsiteEnrichment: typeof projectWebsiteEnrichment;
projectWebsiteMetadata: typeof projectWebsiteMetadata;
projectWebsiteSuggestions: typeof projectWebsiteSuggestions;
projectWebsiteUrl: typeof projectWebsiteUrl;
projects: typeof projects;
+ schemaProactiveTables: typeof schemaProactiveTables;
schemaProductCommon: typeof schemaProductCommon;
schemaProductContentTables: typeof schemaProductContentTables;
schemaProductIdentityTables: typeof schemaProductIdentityTables;
@@ -310,6 +343,7 @@ declare const fullApi: ApiFromModules<{
schemaWorkspaceSourceTables: typeof schemaWorkspaceSourceTables;
schemaWorkspaceTables: typeof schemaWorkspaceTables;
signatures: typeof signatures;
+ sources: typeof sources;
}>;
/**
diff --git a/packages/backend/convex/agent.ts b/packages/backend/convex/agent.ts
new file mode 100644
index 0000000..dea9937
--- /dev/null
+++ b/packages/backend/convex/agent.ts
@@ -0,0 +1,54 @@
+import { internalMutation } from "./_generated/server";
+import { v } from "convex/values";
+
+import { proactiveShipLink } from "./proactiveValidators";
+
+export const linkShipsToNeeds = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ clusterKey: v.optional(v.string()),
+ titleIncludes: v.optional(v.string()),
+ ship: proactiveShipLink,
+ },
+ returns: v.object({ ok: v.literal(true), linked: v.number(), drafted: v.number() }),
+ handler: async (ctx, args) => {
+ const needs = await ctx.db
+ .query("needs")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", args.workspaceId))
+ .collect();
+ const titleNeedle = args.titleIncludes?.trim().toLowerCase();
+ const matches = needs.filter((need) => {
+ if (need.status !== "accepted") return false;
+ if (args.clusterKey && need.clusterKey !== args.clusterKey) return false;
+ if (titleNeedle && !need.title.toLowerCase().includes(titleNeedle)) return false;
+ return Boolean(args.clusterKey || titleNeedle);
+ });
+ const now = Date.now();
+ let drafted = 0;
+ for (const need of matches) {
+ await ctx.db.patch(need._id, {
+ linkedShip: args.ship,
+ conditionFlags: { ...need.conditionFlags, hasLinkedShip: true, digestEligible: true },
+ updatedAt: now,
+ });
+ const existingDraft = await ctx.db
+ .query("draftProposals")
+ .withIndex("by_need", (q) => q.eq("needId", need._id))
+ .first();
+ if (!existingDraft) {
+ await ctx.db.insert("draftProposals", {
+ workspaceId: args.workspaceId,
+ kind: "notify",
+ needId: need._id,
+ needTitle: need.title,
+ draftText: `We shipped ${need.title} in PR #${args.ship.prNumber}. Review before notifying requesters.`,
+ status: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+ drafted += 1;
+ }
+ }
+ return { ok: true as const, linked: matches.length, drafted };
+ },
+});
diff --git a/packages/backend/convex/amendFeedbackCreateMutationHandler.ts b/packages/backend/convex/amendFeedbackCreateMutationHandler.ts
index 3ac7f5a..ab88203 100644
--- a/packages/backend/convex/amendFeedbackCreateMutationHandler.ts
+++ b/packages/backend/convex/amendFeedbackCreateMutationHandler.ts
@@ -1,3 +1,4 @@
+import { internal } from "./_generated/api";
import type { MutationCtx } from "./_generated/server";
import { recordAnalyticsEvent } from "./amendAnalytics";
import { slugPart, workspaceSlug } from "./amendBackendUtils";
@@ -78,6 +79,18 @@ export async function createFeedbackHandler(ctx: MutationCtx, args: CreateFeedba
updatedAt: now,
});
+ await ctx.scheduler.runAfter(0, internal.pipeline.processEvent, {
+ workspaceId: workspace._id,
+ externalId: sourceLink.externalId,
+ text: args.body,
+ title: args.title,
+ author: args.authorName ?? args.authorEmail ?? "Anonymous",
+ url: sourceLink.url,
+ provider: sourceLink.provider,
+ labels: args.labels ?? [],
+ email: args.authorEmail,
+ });
+
await recordAnalyticsEvent(ctx, {
workspaceId: workspace._id,
workspaceSlug: normalizedWorkspaceSlug,
diff --git a/packages/backend/convex/amendSourceIngest.ts b/packages/backend/convex/amendSourceIngest.ts
index 510b969..1d0a9b9 100644
--- a/packages/backend/convex/amendSourceIngest.ts
+++ b/packages/backend/convex/amendSourceIngest.ts
@@ -1,3 +1,4 @@
+import { internal } from "./_generated/api";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx } from "./_generated/server";
import { recordAnalyticsEvent } from "./amendAnalytics";
@@ -79,6 +80,18 @@ export async function trustedIngestSourceEventHandler(
let notificationId: Id<"notifications"> | undefined;
let reviewItemId: Id<"reviewItems"> | undefined;
+ await ctx.scheduler.runAfter(0, internal.pipeline.processEvent, {
+ workspaceId: workspace._id,
+ sourceEventId,
+ externalId: args.externalId,
+ text: [args.title, args.labels?.join(" "), args.milestone].filter(Boolean).join("\n"),
+ title: args.title,
+ author: args.author,
+ url: args.url,
+ provider,
+ labels: args.labels ?? [],
+ });
+
if (isShippedSourceEvent(args)) {
const shippedResult = await handleShippedSourceEvent(ctx, {
args,
diff --git a/packages/backend/convex/backfill.ts b/packages/backend/convex/backfill.ts
new file mode 100644
index 0000000..525b3b0
--- /dev/null
+++ b/packages/backend/convex/backfill.ts
@@ -0,0 +1,29 @@
+import { internalAction } from "./_generated/server";
+import { v } from "convex/values";
+
+import { internal } from "./_generated/api";
+
+export const gitHistory = internalAction({
+ args: {
+ workspaceId: v.id("workspaces"),
+ owner: v.string(),
+ repo: v.string(),
+ },
+ returns: v.object({ ok: v.literal(true), imported: v.number() }),
+ handler: async (ctx, args) => {
+ // Deterministic-first v1 backfill: import a bounded repository-level signal so the
+ // pipeline has an idempotent historical anchor without requiring a GitHub token.
+ await ctx.runMutation(internal.pipeline.commitProcessedEvent, {
+ workspaceId: args.workspaceId,
+ externalId: `github:${args.owner}/${args.repo}:backfill:repository`,
+ dedupeKey: `${args.workspaceId}:github:${args.owner}/${args.repo}:backfill:repository`,
+ title: `Backfilled GitHub history for ${args.owner}/${args.repo}`,
+ text: `Repository ${args.owner}/${args.repo} is connected. Watch merged pull requests, issues, and releases for proactive customer evidence.`,
+ author: "github-backfill",
+ url: `https://github.com/${args.owner}/${args.repo}`,
+ provider: "github",
+ labels: ["github", "backfill"],
+ });
+ return { ok: true as const, imported: 1 };
+ },
+});
diff --git a/packages/backend/convex/changelog.ts b/packages/backend/convex/changelog.ts
new file mode 100644
index 0000000..6fdaa28
--- /dev/null
+++ b/packages/backend/convex/changelog.ts
@@ -0,0 +1,59 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+import { changelogEntryIdArgs, proactiveWorkspaceArgs } from "./proactiveArgs";
+import { okResult, proactiveChangelogEntry } from "./proactiveValidators";
+import { requireProactiveWorkspace } from "./proactiveShared";
+
+function fallbackShip(entry: { publishedAt?: number; sourceLinks: { number?: number; url: string }[] }) {
+ const source = entry.sourceLinks[0];
+ return {
+ prNumber: source?.number ?? 0,
+ sha: "unknown",
+ mergedAt: entry.publishedAt ?? Date.now(),
+ url: source?.url ?? "",
+ };
+}
+
+export const list = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.array(proactiveChangelogEntry),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const entries = await ctx.db
+ .query("changelogEntries")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+ return entries
+ .filter((entry) => entry.publishedAt !== undefined || entry.status === "published")
+ .sort((a, b) => (b.publishedAt ?? b.updatedAt) - (a.publishedAt ?? a.updatedAt))
+ .map((entry) => ({
+ id: entry._id,
+ title: entry.title,
+ body: entry.body,
+ shippedAt: entry.publishedAt ?? entry.updatedAt,
+ ship: fallbackShip(entry),
+ published: entry.status === "published",
+ }));
+ },
+});
+
+export const publish = mutation({
+ args: changelogEntryIdArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const entryId = ctx.db.normalizeId("changelogEntries", args.entryId);
+ if (!entryId) return { ok: true as const };
+ const entry = await ctx.db.get(entryId);
+ if (!entry || entry.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(entry._id, {
+ status: "published",
+ reviewerStatus: "approved",
+ publishedAt: entry.publishedAt ?? now,
+ updatedAt: now,
+ });
+ return { ok: true as const };
+ },
+});
diff --git a/packages/backend/convex/crons.ts b/packages/backend/convex/crons.ts
new file mode 100644
index 0000000..ab6e182
--- /dev/null
+++ b/packages/backend/convex/crons.ts
@@ -0,0 +1,14 @@
+import { cronJobs } from "convex/server";
+
+import { internal } from "./_generated/api";
+
+const crons = cronJobs();
+
+crons.weekly(
+ "send proactive digest previews",
+ { dayOfWeek: "monday", hourUTC: 14, minuteUTC: 0 },
+ internal.digest.sendWeekly,
+ {},
+);
+
+export default crons;
diff --git a/packages/backend/convex/digest.ts b/packages/backend/convex/digest.ts
new file mode 100644
index 0000000..9ea8ae7
--- /dev/null
+++ b/packages/backend/convex/digest.ts
@@ -0,0 +1,101 @@
+import { internalMutation, query } from "./_generated/server";
+import type { Id } from "./_generated/dataModel";
+import type { MutationCtx, QueryCtx } from "./_generated/server";
+import { v } from "convex/values";
+
+import { proactiveWorkspaceArgs } from "./proactiveArgs";
+import { proactiveDigestPreview } from "./proactiveValidators";
+import { ghostSortValue, needToGhost, requireProactiveWorkspace } from "./proactiveShared";
+import { workspaceSlug } from "./amendBackendUtils";
+import { ensureBaseRecords } from "./amendSeed";
+
+function oneWeekWindow(now: number) {
+ return { from: now - 1000 * 60 * 60 * 24 * 7, to: now };
+}
+
+export const preview = query({
+ args: proactiveWorkspaceArgs,
+ returns: proactiveDigestPreview,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ return await buildPreview(ctx, workspace._id, Date.now());
+ },
+});
+
+export const sendWeekly = internalMutation({
+ args: {
+ workspaceSlug: v.optional(v.string()),
+ },
+ returns: v.object({ ok: v.literal(true), queued: v.number() }),
+ handler: async (ctx, args) => {
+ const normalizedWorkspaceSlug = workspaceSlug(args.workspaceSlug);
+ const workspace = await ensureBaseRecords(ctx, normalizedWorkspaceSlug);
+ const now = Date.now();
+ const digest = await buildPreview(ctx, workspace._id, now);
+ if (digest.resolved.length === 0 && digest.readyGhosts.length === 0) {
+ return { ok: true as const, queued: 0 };
+ }
+ const notificationId = await ctx.db.insert("notifications", {
+ workspaceId: workspace._id,
+ stableKey: `weekly-digest-${now}`,
+ title: "Weekly proactive digest",
+ body: digestBody(digest),
+ channel: "email",
+ audience: "admins",
+ status: "queued",
+ priority: "normal",
+ relatedKind: "review",
+ relatedKey: `weekly-digest-${now}`,
+ sourceLinks: [],
+ createdAt: now,
+ updatedAt: now,
+ });
+ const members = await ctx.db
+ .query("workspaceMembers")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+ let queued = 0;
+ for (const member of members.filter((member) => member.role === "owner" || member.role === "admin")) {
+ await ctx.db.insert("deliveryOutbox", {
+ workspaceId: workspace._id,
+ notificationId,
+ channel: "email",
+ recipient: member.email,
+ status: "queued",
+ provider: "weekly_digest",
+ payload: digest,
+ createdAt: now,
+ updatedAt: now,
+ });
+ queued += 1;
+ }
+ return { ok: true as const, queued };
+ },
+});
+
+async function buildPreview(ctx: QueryCtx | MutationCtx, workspaceId: Id<"workspaces">, now: number) {
+ const needs = await ctx.db
+ .query("needs")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .collect();
+ const readyGhosts = needs
+ .filter((need) => need.status === "ghost" && need.conditionFlags.digestEligible)
+ .map(needToGhost)
+ .sort((a, b) => ghostSortValue(b) - ghostSortValue(a))
+ .slice(0, 5);
+ const resolved = needs
+ .filter((need) => need.status === "accepted" && need.linkedShip)
+ .map((need) => ({
+ needTitle: need.title,
+ ship: need.linkedShip!,
+ peopleNotified: need.proofPeople,
+ }));
+ const handledSilently = needs.filter((need) => need.status === "killed").length;
+ return { period: oneWeekWindow(now), resolved, readyGhosts, handledSilently };
+}
+
+function digestBody(digest: Awaited>) {
+ const resolved = digest.resolved.map((item) => `Shipped: ${item.needTitle}`).join("\n");
+ const ghosts = digest.readyGhosts.map((item) => `Ready: ${item.title}`).join("\n");
+ return [resolved, ghosts, `Handled silently: ${digest.handledSilently}`].filter(Boolean).join("\n\n");
+}
diff --git a/packages/backend/convex/drafts.ts b/packages/backend/convex/drafts.ts
new file mode 100644
index 0000000..8bba336
--- /dev/null
+++ b/packages/backend/convex/drafts.ts
@@ -0,0 +1,150 @@
+import { mutation, query } from "./_generated/server";
+import type { MutationCtx } from "./_generated/server";
+import type { Doc } from "./_generated/dataModel";
+import { v } from "convex/values";
+
+import { draftIdArgs, proactiveWorkspaceArgs, rejectDraftArgs } from "./proactiveArgs";
+import { okResult, proactiveDraftProposal } from "./proactiveValidators";
+import { requireProactiveWorkspace } from "./proactiveShared";
+
+function toDraftProposal(draft: {
+ _id: string;
+ kind: "changelog" | "notify";
+ needId: string;
+ needTitle: string;
+ draftText: string;
+ recipients?: { handle: string; channel: string }[];
+ status: "pending" | "approved" | "rejected";
+}) {
+ return {
+ id: draft._id,
+ kind: draft.kind,
+ needId: draft.needId,
+ needTitle: draft.needTitle,
+ draftText: draft.draftText,
+ ...(draft.recipients ? { recipients: draft.recipients } : {}),
+ status: draft.status,
+ };
+}
+
+export const listPending = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.array(proactiveDraftProposal),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const drafts = await ctx.db
+ .query("draftProposals")
+ .withIndex("by_workspace_and_status", (q) =>
+ q.eq("workspaceId", workspace._id).eq("status", "pending"),
+ )
+ .collect();
+ return drafts.sort((a, b) => b.createdAt - a.createdAt).map(toDraftProposal);
+ },
+});
+
+export const approve = mutation({
+ args: draftIdArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const draftId = ctx.db.normalizeId("draftProposals", args.draftId);
+ if (!draftId) return { ok: true as const };
+ const draft = await ctx.db.get(draftId);
+ if (!draft || draft.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(draft._id, { status: "approved", approvedAt: now, updatedAt: now });
+
+ if (draft.kind === "notify") {
+ await queueNotificationDeliveries(ctx, draft, now);
+ } else {
+ await queueChangelogReview(ctx, draft, now);
+ }
+ return { ok: true as const };
+ },
+});
+
+export const reject = mutation({
+ args: rejectDraftArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const draftId = ctx.db.normalizeId("draftProposals", args.draftId);
+ if (!draftId) return { ok: true as const };
+ const draft = await ctx.db.get(draftId);
+ if (!draft || draft.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(draft._id, {
+ status: "rejected",
+ rejectedAt: now,
+ edits: args.edits,
+ updatedAt: now,
+ });
+ return { ok: true as const };
+ },
+});
+
+async function queueNotificationDeliveries(
+ ctx: MutationCtx,
+ draft: Doc<"draftProposals">,
+ now: number,
+) {
+ const notificationId = await ctx.db.insert("notifications", {
+ workspaceId: draft.workspaceId,
+ stableKey: `proactive-${draft._id}`,
+ title: `Shipped: ${draft.needTitle}`,
+ body: safetyStrip(draft.draftText),
+ channel: "email",
+ audience: "subscribers",
+ status: "queued",
+ priority: "normal",
+ relatedKind: "feedback",
+ relatedKey: draft.needId,
+ sourceLinks: [],
+ createdAt: now,
+ updatedAt: now,
+ });
+ for (const recipient of draft.recipients ?? []) {
+ await ctx.db.insert("deliveryOutbox", {
+ workspaceId: draft.workspaceId,
+ notificationId,
+ channel: recipient.channel === "email" ? "email" : "in_app",
+ recipient: recipient.handle,
+ status: "queued",
+ provider: recipient.channel,
+ payload: {
+ body: safetyStrip(draft.draftText),
+ gatedBy: "drafts.approve",
+ title: `Shipped: ${draft.needTitle}`,
+ },
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+}
+
+async function queueChangelogReview(
+ ctx: MutationCtx,
+ draft: Doc<"draftProposals">,
+ now: number,
+) {
+ await ctx.db.insert("reviewItems", {
+ workspaceId: draft.workspaceId,
+ stableKey: `review-proactive-${draft._id}`,
+ kind: "changelog",
+ status: "needs_review",
+ title: `Review proactive changelog: ${draft.needTitle}`,
+ summary: safetyStrip(draft.draftText),
+ targetKey: draft.needId,
+ sourceLinks: [],
+ comments: [],
+ requestedBy: "proactive-agent",
+ createdAt: now,
+ updatedAt: now,
+ });
+}
+
+function safetyStrip(value: string) {
+ return value
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[email]")
+ .replace(/(sk|pk|ghp|gho|github_pat)_[A-Za-z0-9_\-]+/g, "[secret]");
+}
diff --git a/packages/backend/convex/http.ts b/packages/backend/convex/http.ts
index 1607245..95b04da 100644
--- a/packages/backend/convex/http.ts
+++ b/packages/backend/convex/http.ts
@@ -5,6 +5,7 @@ import { authComponent, createAuth } from "./auth";
import { restGet } from "./httpRestGet";
import { restPost } from "./httpRestPost";
import { corsHeaders, json, versionMetadata } from "./httpRuntime";
+import * as ingest from "./ingest";
const http = httpRouter();
@@ -24,6 +25,9 @@ const restOptions = httpAction(async () => {
http.route({ method: "GET", path: "/api/v1/version", handler: versionGet });
http.route({ method: "GET", pathPrefix: "/api/v1/", handler: restGet });
http.route({ method: "POST", pathPrefix: "/api/v1/", handler: restPost });
+http.route({ method: "POST", path: "/ingest/githubWebhook", handler: ingest.githubWebhook });
+http.route({ method: "POST", path: "/ingest/discordWebhook", handler: ingest.discordWebhook });
+http.route({ method: "POST", path: "/ingest/sourceEvent", handler: ingest.sourceEvent });
http.route({ method: "OPTIONS", pathPrefix: "/api/v1/", handler: restOptions });
export default http;
diff --git a/packages/backend/convex/ingest.ts b/packages/backend/convex/ingest.ts
new file mode 100644
index 0000000..618a630
--- /dev/null
+++ b/packages/backend/convex/ingest.ts
@@ -0,0 +1,41 @@
+import { httpAction } from "./_generated/server";
+import { api, internal } from "./_generated/api";
+import { githubSourceEvent, sourceEventFromBody } from "./httpRuntimeSourceEventInputs";
+import { json, readBody } from "./httpRuntimeRouting";
+import { optionalString, record } from "./httpRuntimeScalars";
+
+export const githubWebhook = httpAction(async (ctx, request) => {
+ const rawBody = await request.text();
+ const body = readBody(rawBody);
+ const workspaceSlug = optionalString(body.workspaceSlug) ?? "demo";
+ const result = await ctx.runMutation(internal.amend.trustedIngestSourceEvent, {
+ workspaceSlug,
+ ...githubSourceEvent(request, body),
+ });
+ return json({ ok: true, result }, 202);
+});
+
+export const discordWebhook = httpAction(async (ctx, request) => {
+ const body = readBody(await request.text());
+ const workspaceSlug = optionalString(body.workspaceSlug) ?? "demo";
+ const message = record(body.message) ?? body;
+ const result = await ctx.runMutation(api.amend.createFeedback, {
+ workspaceSlug,
+ authorName: optionalString(message.author) ?? optionalString(message.username) ?? "Discord",
+ body: optionalString(message.text) ?? optionalString(message.content) ?? "",
+ labels: ["discord"],
+ sourceUrl: optionalString(message.url),
+ title: optionalString(message.title) ?? "Discord feedback",
+ });
+ return json({ ok: true, result }, 202);
+});
+
+export const sourceEvent = httpAction(async (ctx, request) => {
+ const body = readBody(await request.text());
+ const workspaceSlug = optionalString(body.workspaceSlug) ?? "demo";
+ const result = await ctx.runMutation(internal.amend.trustedIngestSourceEvent, {
+ workspaceSlug,
+ ...sourceEventFromBody(body),
+ });
+ return json({ ok: true, result }, 202);
+});
diff --git a/packages/backend/convex/memory.ts b/packages/backend/convex/memory.ts
new file mode 100644
index 0000000..88ba589
--- /dev/null
+++ b/packages/backend/convex/memory.ts
@@ -0,0 +1,68 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+import { memoryRuleIdArgs, proactiveWorkspaceArgs, toggleMemoryRuleArgs } from "./proactiveArgs";
+import { okResult, proactiveMemoryRule } from "./proactiveValidators";
+import { requireProactiveWorkspace } from "./proactiveShared";
+
+function toMemoryRule(rule: {
+ _id: string;
+ kind: "noise" | "dedupe" | "addressed" | "allowlist" | "pattern";
+ text: string;
+ taughtBy: string;
+ taughtAt: number;
+ blastRadius: number;
+ enabled: boolean;
+}) {
+ return {
+ id: rule._id,
+ kind: rule.kind,
+ text: rule.text,
+ taughtBy: rule.taughtBy,
+ taughtAt: rule.taughtAt,
+ blastRadius: rule.blastRadius,
+ enabled: rule.enabled,
+ };
+}
+
+export const listRules = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.array(proactiveMemoryRule),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const rules = await ctx.db
+ .query("memoryRules")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .collect();
+ return rules.sort((a, b) => b.taughtAt - a.taughtAt).map(toMemoryRule);
+ },
+});
+
+export const toggleRule = mutation({
+ args: toggleMemoryRuleArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const ruleId = ctx.db.normalizeId("memoryRules", args.ruleId);
+ if (!ruleId) return { ok: true as const };
+ const rule = await ctx.db.get(ruleId);
+ if (!rule || rule.workspaceId !== workspace._id) return { ok: true as const };
+ await ctx.db.patch(rule._id, { enabled: args.enabled, updatedAt: Date.now() });
+ return { ok: true as const };
+ },
+});
+
+export const undoRule = mutation({
+ args: memoryRuleIdArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const ruleId = ctx.db.normalizeId("memoryRules", args.ruleId);
+ if (!ruleId) return { ok: true as const };
+ const rule = await ctx.db.get(ruleId);
+ if (!rule || rule.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(rule._id, { enabled: false, undoneAt: now, updatedAt: now });
+ return { ok: true as const };
+ },
+});
diff --git a/packages/backend/convex/needs.ts b/packages/backend/convex/needs.ts
new file mode 100644
index 0000000..c1f4893
--- /dev/null
+++ b/packages/backend/convex/needs.ts
@@ -0,0 +1,136 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+import { ghostIdArgs, killGhostArgs, needIdArgs, proactiveWorkspaceArgs } from "./proactiveArgs";
+import {
+ okResult,
+ proactiveGhost,
+ proactiveNeed,
+} from "./proactiveValidators";
+import {
+ ghostSortValue,
+ needToAcceptedNeed,
+ needToGhost,
+ requireProactiveWorkspace,
+} from "./proactiveShared";
+
+export const listGhosts = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.array(proactiveGhost),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const needs = await ctx.db
+ .query("needs")
+ .withIndex("by_workspace_and_status", (q) =>
+ q.eq("workspaceId", workspace._id).eq("status", "ghost"),
+ )
+ .collect();
+ return needs.map(needToGhost).sort((a, b) => ghostSortValue(b) - ghostSortValue(a));
+ },
+});
+
+export const listAccepted = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.array(proactiveNeed),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const needs = await ctx.db
+ .query("needs")
+ .withIndex("by_workspace_and_status", (q) =>
+ q.eq("workspaceId", workspace._id).eq("status", "accepted"),
+ )
+ .collect();
+ return await Promise.all(
+ needs.sort((a, b) => b.lastSeen - a.lastSeen).map((need) => needToAcceptedNeed(ctx, need)),
+ );
+ },
+});
+
+export const get = query({
+ args: needIdArgs,
+ returns: v.union(proactiveNeed, v.null()),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const needId = ctx.db.normalizeId("needs", args.needId);
+ if (!needId) return null;
+ const need = await ctx.db.get(needId);
+ if (!need || need.workspaceId !== workspace._id || need.status !== "accepted") return null;
+ return await needToAcceptedNeed(ctx, need);
+ },
+});
+
+export const acceptGhost = mutation({
+ args: ghostIdArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const ghostId = ctx.db.normalizeId("needs", args.ghostId);
+ if (!ghostId) return { ok: true as const };
+ const ghost = await ctx.db.get(ghostId);
+ if (!ghost || ghost.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(ghost._id, {
+ status: "accepted",
+ updatedAt: now,
+ conditionFlags: { ...ghost.conditionFlags, readyForReview: false, digestEligible: true },
+ });
+ await ctx.db.insert("draftProposals", {
+ workspaceId: workspace._id,
+ kind: "changelog",
+ needId: ghost._id,
+ needTitle: ghost.title,
+ draftText: `Draft a customer-safe update for: ${ghost.title}`,
+ status: "pending",
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { ok: true as const };
+ },
+});
+
+export const keepGathering = mutation({
+ args: ghostIdArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const ghostId = ctx.db.normalizeId("needs", args.ghostId);
+ if (!ghostId) return { ok: true as const };
+ const ghost = await ctx.db.get(ghostId);
+ if (!ghost || ghost.workspaceId !== workspace._id) return { ok: true as const };
+ await ctx.db.patch(ghost._id, {
+ conditionFlags: { ...ghost.conditionFlags, readyForReview: false },
+ updatedAt: Date.now(),
+ });
+ return { ok: true as const };
+ },
+});
+
+export const killGhost = mutation({
+ args: killGhostArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const ghostId = ctx.db.normalizeId("needs", args.ghostId);
+ if (!ghostId) return { ok: true as const };
+ const ghost = await ctx.db.get(ghostId);
+ if (!ghost || ghost.workspaceId !== workspace._id) return { ok: true as const };
+ const now = Date.now();
+ await ctx.db.patch(ghost._id, {
+ status: "killed",
+ updatedAt: now,
+ conditionFlags: { ...ghost.conditionFlags, readyForReview: false, digestEligible: false },
+ });
+ await ctx.db.insert("memoryRules", {
+ workspaceId: workspace._id,
+ kind: "noise",
+ text: args.reason?.trim() || `Suppress similar ghost: ${ghost.title}`,
+ taughtBy: "human",
+ taughtAt: now,
+ blastRadius: Math.max(1, ghost.proofPeople),
+ enabled: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ return { ok: true as const };
+ },
+});
diff --git a/packages/backend/convex/pipeline.ts b/packages/backend/convex/pipeline.ts
new file mode 100644
index 0000000..36dfeea
--- /dev/null
+++ b/packages/backend/convex/pipeline.ts
@@ -0,0 +1,208 @@
+import { internalAction, internalMutation } from "./_generated/server";
+import type { Id } from "./_generated/dataModel";
+import type { MutationCtx } from "./_generated/server";
+import { v } from "convex/values";
+
+import { internal } from "./_generated/api";
+import { channelFromProvider, classifySignal } from "./proactiveClassifier";
+import { recomputeNeedProof, resolvePersonForEvidence } from "./proactiveProof";
+
+export const processEvent = internalAction({
+ args: {
+ workspaceId: v.id("workspaces"),
+ sourceEventId: v.optional(v.id("sourceEvents")),
+ externalId: v.string(),
+ text: v.string(),
+ title: v.optional(v.string()),
+ author: v.optional(v.string()),
+ url: v.optional(v.string()),
+ provider: v.optional(v.string()),
+ labels: v.optional(v.array(v.string())),
+ email: v.optional(v.string()),
+ accountId: v.optional(v.string()),
+ },
+ returns: v.object({ ok: v.literal(true) }),
+ handler: async (ctx, args) => {
+ await ctx.runMutation(internal.pipeline.commitProcessedEvent, {
+ ...args,
+ dedupeKey: `${args.workspaceId}:${args.externalId}`,
+ });
+ return { ok: true as const };
+ },
+});
+
+export const commitProcessedEvent = internalMutation({
+ args: {
+ workspaceId: v.id("workspaces"),
+ sourceEventId: v.optional(v.id("sourceEvents")),
+ externalId: v.string(),
+ dedupeKey: v.string(),
+ text: v.string(),
+ title: v.optional(v.string()),
+ author: v.optional(v.string()),
+ url: v.optional(v.string()),
+ provider: v.optional(v.string()),
+ labels: v.optional(v.array(v.string())),
+ email: v.optional(v.string()),
+ accountId: v.optional(v.string()),
+ },
+ returns: v.object({ ok: v.literal(true), state: v.string() }),
+ handler: async (ctx, args) => {
+ const now = Date.now();
+ const existing = await ctx.db
+ .query("proactivePipelineEvents")
+ .withIndex("by_workspace_and_dedupeKey", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("dedupeKey", args.dedupeKey),
+ )
+ .unique();
+ if (existing?.state === "processed") return { ok: true as const, state: "processed" };
+
+ const channel = channelFromProvider(args.provider);
+ const classification = classifySignal({
+ channel,
+ labels: args.labels,
+ text: args.text,
+ title: args.title,
+ });
+ const suppressedByMemory = await isSuppressedByMemory(ctx, {
+ text: `${args.title ?? ""}\n${args.text}\n${args.labels?.join(" ") ?? ""}`,
+ workspaceId: args.workspaceId,
+ });
+ const suppressed = classification.isNoise || suppressedByMemory;
+
+ await upsertPipelineEvent(ctx, {
+ dedupeKey: args.dedupeKey,
+ existingId: existing?._id,
+ externalId: args.externalId,
+ sourceEventId: args.sourceEventId,
+ state: suppressed ? "suppressed" : "processed",
+ workspaceId: args.workspaceId,
+ });
+
+ if (suppressed) return { ok: true as const, state: "suppressed" };
+
+ const personId = await resolvePersonForEvidence(ctx, {
+ accountId: args.accountId,
+ channel,
+ email: args.email,
+ handle: args.author,
+ name: args.author,
+ workspaceId: args.workspaceId,
+ });
+
+ const need = await findCompatibleNeed(ctx, {
+ clusterKey: classification.clusterKey,
+ workspaceId: args.workspaceId,
+ });
+ const needId = need
+ ? need._id
+ : await ctx.db.insert("needs", {
+ workspaceId: args.workspaceId,
+ title: args.title ?? titleFromText(args.text),
+ status: "ghost",
+ proofPeople: 0,
+ proofPayingPeople: 0,
+ proofSources: [],
+ proofStrength: "thin",
+ proofGrowthPerWeek: 0,
+ sampleQuotes: [],
+ firstSeen: now,
+ lastSeen: now,
+ conditionFlags: { readyForReview: false, hasLinkedShip: false, digestEligible: true },
+ clusterKey: classification.clusterKey,
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ const existingEvidence = args.sourceEventId
+ ? await ctx.db
+ .query("evidence")
+ .withIndex("by_sourceEvent", (q) => q.eq("sourceEventId", args.sourceEventId))
+ .first()
+ : null;
+ if (!existingEvidence) {
+ await ctx.db.insert("evidence", {
+ workspaceId: args.workspaceId,
+ needId,
+ sourceEventId: args.sourceEventId,
+ personId,
+ sourceChannel: channel,
+ author: args.author ?? "Unknown",
+ handle: args.author,
+ text: args.text,
+ url: args.url ?? "",
+ confidenceBucket: classification.confidenceBucket,
+ promotedBy: "agent",
+ createdAt: now,
+ });
+ }
+ await recomputeNeedProof(ctx, needId);
+ return { ok: true as const, state: "processed" };
+ },
+});
+
+async function isSuppressedByMemory(
+ ctx: MutationCtx,
+ args: { text: string; workspaceId: Id<"workspaces"> },
+) {
+ const normalizedText = args.text.toLowerCase();
+ const enabledRules = await ctx.db
+ .query("memoryRules")
+ .withIndex("by_workspace_and_enabled", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("enabled", true),
+ )
+ .collect();
+ return enabledRules.some((rule) => {
+ if (rule.kind !== "noise" && rule.kind !== "addressed") return false;
+ const normalizedRule = rule.text.toLowerCase().trim();
+ return normalizedRule.length > 0 && normalizedText.includes(normalizedRule);
+ });
+}
+
+async function findCompatibleNeed(
+ ctx: MutationCtx,
+ args: { clusterKey: string; workspaceId: Id<"workspaces"> },
+) {
+ return await ctx.db
+ .query("needs")
+ .withIndex("by_workspace_and_clusterKey", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("clusterKey", args.clusterKey),
+ )
+ .first();
+}
+
+function titleFromText(text: string) {
+ return text.replace(/\s+/g, " ").trim().slice(0, 80) || "Customer signal";
+}
+
+async function upsertPipelineEvent(
+ ctx: MutationCtx,
+ args: {
+ dedupeKey: string;
+ existingId?: Id<"proactivePipelineEvents">;
+ externalId: string;
+ sourceEventId?: Id<"sourceEvents">;
+ state: "queued" | "processed" | "suppressed" | "failed";
+ workspaceId: Id<"workspaces">;
+ },
+) {
+ const now = Date.now();
+ const patch = {
+ sourceEventId: args.sourceEventId,
+ state: args.state,
+ updatedAt: now,
+ };
+ if (args.existingId) {
+ await ctx.db.patch(args.existingId, patch);
+ return;
+ }
+ await ctx.db.insert("proactivePipelineEvents", {
+ workspaceId: args.workspaceId,
+ sourceEventId: args.sourceEventId,
+ externalId: args.externalId,
+ state: args.state,
+ dedupeKey: args.dedupeKey,
+ createdAt: now,
+ updatedAt: now,
+ });
+}
diff --git a/packages/backend/convex/proactiveArgs.ts b/packages/backend/convex/proactiveArgs.ts
new file mode 100644
index 0000000..a73a4d5
--- /dev/null
+++ b/packages/backend/convex/proactiveArgs.ts
@@ -0,0 +1,51 @@
+import { v } from "convex/values";
+
+export const proactiveWorkspaceArgs = {
+ workspaceSlug: v.optional(v.string()),
+};
+
+export const needIdArgs = {
+ ...proactiveWorkspaceArgs,
+ needId: v.string(),
+};
+
+export const ghostIdArgs = {
+ ...proactiveWorkspaceArgs,
+ ghostId: v.string(),
+};
+
+export const killGhostArgs = {
+ ...ghostIdArgs,
+ reason: v.optional(v.string()),
+};
+
+export const draftIdArgs = {
+ ...proactiveWorkspaceArgs,
+ draftId: v.string(),
+};
+
+export const rejectDraftArgs = {
+ ...draftIdArgs,
+ edits: v.optional(v.string()),
+};
+
+export const memoryRuleIdArgs = {
+ ...proactiveWorkspaceArgs,
+ ruleId: v.string(),
+};
+
+export const toggleMemoryRuleArgs = {
+ ...memoryRuleIdArgs,
+ enabled: v.boolean(),
+};
+
+export const changelogEntryIdArgs = {
+ ...proactiveWorkspaceArgs,
+ entryId: v.string(),
+};
+
+export const connectGithubArgs = {
+ ...proactiveWorkspaceArgs,
+ repo: v.string(),
+ token: v.string(),
+};
diff --git a/packages/backend/convex/proactiveClassifier.ts b/packages/backend/convex/proactiveClassifier.ts
new file mode 100644
index 0000000..53891ce
--- /dev/null
+++ b/packages/backend/convex/proactiveClassifier.ts
@@ -0,0 +1,100 @@
+import type { Doc } from "./_generated/dataModel";
+
+type SourceChannel = Doc<"evidence">["sourceChannel"];
+
+type SignalClassification = {
+ area: string;
+ verb: string;
+ polarity: "positive" | "negative" | "neutral";
+ platform: SourceChannel;
+ clusterKey: string;
+ confidenceBucket: Doc<"evidence">["confidenceBucket"];
+};
+
+const AREA_KEYWORDS: Array<[string, string[]]> = [
+ ["export", ["export", "csv", "download", "xlsx", "spreadsheet"]],
+ ["import", ["import", "upload", "csv import", "migrate", "migration"]],
+ ["notification", ["notify", "notification", "email", "digest", "alert"]],
+ ["roadmap", ["roadmap", "status", "planned", "vote"]],
+ ["changelog", ["changelog", "release note", "announcement", "publish"]],
+ ["integration", ["github", "slack", "discord", "linear", "webhook"]],
+ ["auth", ["login", "sign in", "signup", "auth", "password"]],
+ ["billing", ["billing", "price", "pricing", "plan", "paid"]],
+];
+
+const VERB_KEYWORDS: Array<[string, string[]]> = [
+ ["create", ["create", "add", "new", "submit"]],
+ ["read", ["view", "see", "show", "list", "find"]],
+ ["update", ["edit", "update", "change", "move", "rename"]],
+ ["delete", ["delete", "remove", "archive", "hide"]],
+ ["connect", ["connect", "sync", "integrate", "webhook"]],
+ ["send", ["send", "notify", "email", "digest", "alert"]],
+ ["export", ["export", "download"]],
+ ["import", ["import", "upload", "migrate"]],
+];
+
+const NOISE_TERMS = ["pricing", "too expensive", "random", "spam", "test", "asdf"];
+const NEGATIVE_TERMS = ["can't", "cannot", "broken", "bad", "bug", "hate", "missing", "need", "should"];
+const POSITIVE_TERMS = ["love", "great", "works", "thanks", "nice"];
+
+export function classifySignal(input: {
+ channel: SourceChannel;
+ labels?: string[];
+ text: string;
+ title?: string;
+}): SignalClassification & { isNoise: boolean } {
+ const text = normalize(`${input.title ?? ""}\n${input.text}\n${input.labels?.join(" ") ?? ""}`);
+ const verb = pickKeyword(text, VERB_KEYWORDS) ?? "request";
+ const verbArea = verb === "export" || verb === "import" ? verb : undefined;
+ const area = verbArea ?? pickKeyword(text, AREA_KEYWORDS) ?? slugToken(text) ?? "general";
+ const polarity = NEGATIVE_TERMS.some((term) => text.includes(term))
+ ? "negative"
+ : POSITIVE_TERMS.some((term) => text.includes(term))
+ ? "positive"
+ : "neutral";
+ const clusterKey = `${area}:${verb}:${input.channel}`;
+ const confidenceBucket = text.length > 80 ? "clear" : text.length > 30 ? "worth-a-look" : "unsure";
+ const isNoise = NOISE_TERMS.some((term) => text === term || text.includes(`just ${term}`));
+ return { area, verb, polarity, platform: input.channel, clusterKey, confidenceBucket, isNoise };
+}
+
+export function facetsCompatible(
+ a: Pick,
+ b: Pick,
+) {
+ if (a.area !== b.area) return false;
+ if (hardFacetMismatch(a.verb, b.verb)) return false;
+ // Same area and compatible verb can merge across source platforms; platform is retained in proof.sources.
+ return true;
+}
+
+export function strengthForProof(people: number, payingPeople: number) {
+ if (payingPeople >= 2 || people >= 6) return "strong" as const;
+ if (payingPeople >= 1 || people >= 3) return "building" as const;
+ return "thin" as const;
+}
+
+export function channelFromProvider(provider: string | undefined): SourceChannel {
+ if (provider === "github") return "github";
+ if (provider === "discord") return "discord";
+ if (provider === "support" || provider === "email" || provider === "slack") return "support";
+ return "embed";
+}
+
+function hardFacetMismatch(a: string, b: string) {
+ return (a === "export" && b === "import") || (a === "import" && b === "export");
+}
+
+function pickKeyword(text: string, entries: Array<[string, string[]]>) {
+ return entries.find(([, terms]) => terms.some((term) => text.includes(term)))?.[0];
+}
+
+function slugToken(text: string) {
+ return text
+ .split(/\s+/)
+ .find((word) => word.length > 4 && !["would", "should", "could", "there", "their", "about"].includes(word));
+}
+
+function normalize(value: string) {
+ return value.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
+}
diff --git a/packages/backend/convex/proactiveProof.ts b/packages/backend/convex/proactiveProof.ts
new file mode 100644
index 0000000..aa1dee4
--- /dev/null
+++ b/packages/backend/convex/proactiveProof.ts
@@ -0,0 +1,130 @@
+import type { Doc, Id } from "./_generated/dataModel";
+import type { MutationCtx } from "./_generated/server";
+import { strengthForProof } from "./proactiveClassifier";
+
+export async function resolvePersonForEvidence(
+ ctx: MutationCtx,
+ args: {
+ accountId?: string;
+ channel: Doc<"evidence">["sourceChannel"];
+ email?: string;
+ handle?: string;
+ name?: string;
+ workspaceId: Id<"workspaces">;
+ },
+) {
+ const email = args.email?.trim().toLowerCase();
+ const handle = args.handle?.trim().toLowerCase();
+ const accountId = args.accountId?.trim();
+ const now = Date.now();
+
+ if (email) {
+ const existing = await ctx.db
+ .query("persons")
+ .withIndex("by_workspace_and_email", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("verifiedEmail", email),
+ )
+ .unique();
+ if (existing) return existing._id;
+ }
+
+ if (accountId) {
+ const existing = await ctx.db
+ .query("persons")
+ .withIndex("by_workspace_and_accountId", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("accountId", accountId),
+ )
+ .unique();
+ if (existing) return existing._id;
+ }
+
+ if (handle) {
+ const identity = await ctx.db
+ .query("identityHandles")
+ .withIndex("by_workspace_and_provider_and_handle", (q) =>
+ q.eq("workspaceId", args.workspaceId).eq("provider", args.channel).eq("handle", handle),
+ )
+ .unique();
+ if (identity?.verified) return identity.personId;
+ }
+
+ const personId = await ctx.db.insert("persons", {
+ workspaceId: args.workspaceId,
+ displayName: args.name?.trim() || handle || email || "Unknown requester",
+ ...(email ? { verifiedEmail: email } : {}),
+ ...(accountId ? { accountId } : {}),
+ paying: Boolean(accountId),
+ createdAt: now,
+ updatedAt: now,
+ });
+
+ if (handle) {
+ await ctx.db.insert("identityHandles", {
+ workspaceId: args.workspaceId,
+ personId,
+ provider: args.channel,
+ handle,
+ verified: true,
+ createdAt: now,
+ updatedAt: now,
+ });
+ }
+
+ return personId;
+}
+
+export async function recomputeNeedProof(ctx: MutationCtx, needId: Id<"needs">) {
+ const need = await ctx.db.get(needId);
+ if (!need) return;
+ const evidence = await ctx.db
+ .query("evidence")
+ .withIndex("by_need", (q) => q.eq("needId", needId))
+ .collect();
+ const personIds = new Set();
+ const anonymousKeys = new Set();
+ const sources = new Map["sourceChannel"], number>();
+ let payingPeople = 0;
+ const payingPersonIds = new Set();
+
+ for (const item of evidence) {
+ sources.set(item.sourceChannel, (sources.get(item.sourceChannel) ?? 0) + 1);
+ if (item.personId) {
+ personIds.add(item.personId);
+ const person = await ctx.db.get(item.personId);
+ if (person?.paying) payingPersonIds.add(item.personId);
+ } else {
+ anonymousKeys.add(`${item.sourceChannel}:${item.author}:${item.url}`);
+ }
+ }
+ payingPeople = payingPersonIds.size;
+ const people = personIds.size + anonymousKeys.size;
+ const firstSeen = evidence.reduce((min, item) => Math.min(min, item.createdAt), need.firstSeen);
+ const lastSeen = evidence.reduce((max, item) => Math.max(max, item.createdAt), need.lastSeen);
+ const weekAgo = Date.now() - 1000 * 60 * 60 * 24 * 7;
+ const growthPerWeek = evidence.filter((item) => item.createdAt >= weekAgo).length;
+
+ await ctx.db.patch(needId, {
+ proofPeople: people,
+ proofPayingPeople: payingPeople,
+ proofSources: [...sources.entries()].map(([channel, count]) => ({ channel, count })),
+ proofStrength: strengthForProof(people, payingPeople),
+ proofGrowthPerWeek: growthPerWeek,
+ firstSeen,
+ lastSeen,
+ sampleQuotes: evidence
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .slice(0, 3)
+ .map((item) => ({
+ text: item.text,
+ author: item.author,
+ channel: item.sourceChannel,
+ url: item.url,
+ })),
+ conditionFlags: {
+ ...need.conditionFlags,
+ readyForReview: people >= 3 || payingPeople >= 1,
+ digestEligible: need.status !== "killed",
+ },
+ updatedAt: Date.now(),
+ });
+}
diff --git a/packages/backend/convex/proactiveShared.ts b/packages/backend/convex/proactiveShared.ts
new file mode 100644
index 0000000..c546eea
--- /dev/null
+++ b/packages/backend/convex/proactiveShared.ts
@@ -0,0 +1,231 @@
+import type { Doc, Id } from "./_generated/dataModel";
+import type { MutationCtx, QueryCtx } from "./_generated/server";
+import { DEMO_NOW } from "./amendDemoData";
+import { requireDashboardUser, requireDashboardWorkspace } from "./amendWorkspace";
+
+export type ProactiveCtx = QueryCtx | MutationCtx;
+
+export type WorkspaceArgs = {
+ workspaceSlug?: string;
+};
+
+export type ProactiveGhost = {
+ id: string;
+ workspaceId: string;
+ title: string;
+ status: "ghost" | "accepted" | "killed";
+ proof: {
+ people: number;
+ payingPeople: number;
+ sources: { channel: "discord" | "support" | "github" | "embed"; count: number }[];
+ strength: "thin" | "building" | "strong";
+ growthPerWeek: number;
+ };
+ sampleQuotes: { text: string; author: string; channel: "discord" | "support" | "github" | "embed"; url: string }[];
+ firstSeen: number;
+ lastSeen: number;
+};
+
+export type ProactiveEvidence = {
+ id: string;
+ sourceChannel: "discord" | "support" | "github" | "embed";
+ author: string;
+ text: string;
+ url: string;
+ confidenceBucket: "clear" | "worth-a-look" | "unsure";
+ promotedBy: "agent" | "human";
+};
+
+export type ProactiveNeed = ProactiveGhost & {
+ status: "accepted";
+ evidence: ProactiveEvidence[];
+ linkedShip?: {
+ prNumber: number;
+ sha: string;
+ releaseTag?: string;
+ mergedAt: number;
+ url: string;
+ };
+};
+
+export async function requireProactiveWorkspace(ctx: ProactiveCtx, args: WorkspaceArgs = {}) {
+ const user = await requireDashboardUser(ctx);
+ return await requireDashboardWorkspace(ctx, user, args.workspaceSlug);
+}
+
+export function needToGhost(need: Doc<"needs">): ProactiveGhost {
+ return {
+ id: need._id,
+ workspaceId: need.workspaceId,
+ title: need.title,
+ status: need.status,
+ proof: {
+ people: need.proofPeople,
+ payingPeople: need.proofPayingPeople,
+ sources: need.proofSources,
+ strength: need.proofStrength,
+ growthPerWeek: need.proofGrowthPerWeek,
+ },
+ sampleQuotes: need.sampleQuotes.slice(0, 3),
+ firstSeen: need.firstSeen,
+ lastSeen: need.lastSeen,
+ };
+}
+
+export async function needToAcceptedNeed(
+ ctx: ProactiveCtx,
+ need: Doc<"needs">,
+): Promise {
+ const evidenceDocs = await ctx.db
+ .query("evidence")
+ .withIndex("by_need", (q) => q.eq("needId", need._id))
+ .collect();
+
+ return {
+ ...needToGhost(need),
+ status: "accepted",
+ evidence: evidenceDocs
+ .sort((a, b) => b.createdAt - a.createdAt)
+ .map((evidence) => ({
+ id: evidence._id,
+ sourceChannel: evidence.sourceChannel,
+ author: evidence.author,
+ text: evidence.text,
+ url: evidence.url,
+ confidenceBucket: evidence.confidenceBucket,
+ promotedBy: evidence.promotedBy,
+ })),
+ ...(need.linkedShip ? { linkedShip: need.linkedShip } : {}),
+ };
+}
+
+export function ghostSortValue(ghost: ProactiveGhost) {
+ const strength = ghost.proof.strength === "strong" ? 3 : ghost.proof.strength === "building" ? 2 : 1;
+ return strength * 1_000_000 + ghost.proof.people * 1_000 + ghost.proof.growthPerWeek;
+}
+
+export async function ensureFixtureNeeds(ctx: MutationCtx, workspaceId: Id<"workspaces">) {
+ const existing = await ctx.db
+ .query("needs")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspaceId))
+ .first();
+ if (existing) return;
+
+ const now = DEMO_NOW;
+ const exportNeedId = await ctx.db.insert("needs", {
+ workspaceId,
+ title: "Customers keep asking for CSV export workflows",
+ status: "ghost",
+ proofPeople: 7,
+ proofPayingPeople: 3,
+ proofSources: [
+ { channel: "support", count: 3 },
+ { channel: "github", count: 2 },
+ { channel: "discord", count: 2 },
+ ],
+ proofStrength: "strong",
+ proofGrowthPerWeek: 2,
+ sampleQuotes: [
+ {
+ text: "We need a clean CSV export before we can roll Amend out to the support team.",
+ author: "Maya",
+ channel: "support",
+ url: "https://example.com/support/182",
+ },
+ {
+ text: "Exporting tagged feedback would unblock our weekly planning ritual.",
+ author: "@sam",
+ channel: "discord",
+ url: "https://discord.com/channels/amend/feedback/42",
+ },
+ ],
+ firstSeen: now - 1000 * 60 * 60 * 24 * 24,
+ lastSeen: now - 1000 * 60 * 60 * 7,
+ conditionFlags: { readyForReview: true, hasLinkedShip: false, digestEligible: true },
+ clusterKey: "csv-export-workflows",
+ createdAt: now - 1000 * 60 * 60 * 24 * 24,
+ updatedAt: now - 1000 * 60 * 60 * 7,
+ });
+
+ const acceptedNeedId = await ctx.db.insert("needs", {
+ workspaceId,
+ title: "Notify requesters when their issue ships",
+ status: "accepted",
+ proofPeople: 4,
+ proofPayingPeople: 2,
+ proofSources: [
+ { channel: "github", count: 2 },
+ { channel: "embed", count: 2 },
+ ],
+ proofStrength: "building",
+ proofGrowthPerWeek: 1,
+ sampleQuotes: [
+ {
+ text: "If someone asked for the feature, they should hear when it lands.",
+ author: "@lee",
+ channel: "github",
+ url: "https://github.com/acme/app/issues/77",
+ },
+ ],
+ firstSeen: now - 1000 * 60 * 60 * 24 * 18,
+ lastSeen: now - 1000 * 60 * 60 * 48,
+ linkedShip: {
+ prNumber: 124,
+ sha: "8c4f2d9",
+ mergedAt: now - 1000 * 60 * 60 * 12,
+ url: "https://github.com/acme/app/pull/124",
+ },
+ conditionFlags: { readyForReview: false, hasLinkedShip: true, digestEligible: true },
+ clusterKey: "ship-notifications",
+ createdAt: now - 1000 * 60 * 60 * 24 * 18,
+ updatedAt: now - 1000 * 60 * 60 * 12,
+ });
+
+ await ctx.db.insert("evidence", {
+ workspaceId,
+ needId: acceptedNeedId,
+ sourceChannel: "github",
+ author: "@lee",
+ text: "If someone asked for the feature, they should hear when it lands.",
+ url: "https://github.com/acme/app/issues/77",
+ confidenceBucket: "clear",
+ promotedBy: "agent",
+ createdAt: now - 1000 * 60 * 60 * 48,
+ });
+
+ await ctx.db.insert("draftProposals", {
+ workspaceId,
+ kind: "notify",
+ needId: acceptedNeedId,
+ needTitle: "Notify requesters when their issue ships",
+ draftText: "Good news — the requester notification workflow shipped in PR #124. Want us to notify the people who asked for it?",
+ recipients: [{ handle: "@lee", channel: "github" }],
+ status: "pending",
+ createdAt: now - 1000 * 60 * 60 * 11,
+ updatedAt: now - 1000 * 60 * 60 * 11,
+ });
+
+ await ctx.db.insert("memoryRules", {
+ workspaceId,
+ kind: "noise",
+ text: "Ignore one-off generic pricing complaints unless tied to a specific workflow.",
+ taughtBy: "demo-admin",
+ taughtAt: now - 1000 * 60 * 60 * 24 * 6,
+ blastRadius: 3,
+ enabled: true,
+ createdAt: now - 1000 * 60 * 60 * 24 * 6,
+ updatedAt: now - 1000 * 60 * 60 * 24 * 6,
+ });
+
+ await ctx.db.insert("evidence", {
+ workspaceId,
+ needId: exportNeedId,
+ sourceChannel: "support",
+ author: "Maya",
+ text: "We need a clean CSV export before we can roll Amend out to the support team.",
+ url: "https://example.com/support/182",
+ confidenceBucket: "clear",
+ promotedBy: "human",
+ createdAt: now - 1000 * 60 * 60 * 24 * 14,
+ });
+}
diff --git a/packages/backend/convex/proactiveValidators.ts b/packages/backend/convex/proactiveValidators.ts
new file mode 100644
index 0000000..017b9f5
--- /dev/null
+++ b/packages/backend/convex/proactiveValidators.ts
@@ -0,0 +1,157 @@
+import { v } from "convex/values";
+
+export const proactiveStatusValue = v.union(
+ v.literal("ghost"),
+ v.literal("accepted"),
+ v.literal("killed"),
+);
+
+export const proactiveSourceChannelValue = v.union(
+ v.literal("discord"),
+ v.literal("support"),
+ v.literal("github"),
+ v.literal("embed"),
+);
+
+export const proactiveStrengthValue = v.union(
+ v.literal("thin"),
+ v.literal("building"),
+ v.literal("strong"),
+);
+
+export const proactiveConfidenceBucketValue = v.union(
+ v.literal("clear"),
+ v.literal("worth-a-look"),
+ v.literal("unsure"),
+);
+
+export const proactivePromotedByValue = v.union(v.literal("agent"), v.literal("human"));
+
+export const proactiveDraftKindValue = v.union(v.literal("changelog"), v.literal("notify"));
+
+export const proactiveDraftStatusValue = v.union(
+ v.literal("pending"),
+ v.literal("approved"),
+ v.literal("rejected"),
+);
+
+export const proactiveMemoryKindValue = v.union(
+ v.literal("noise"),
+ v.literal("dedupe"),
+ v.literal("addressed"),
+ v.literal("allowlist"),
+ v.literal("pattern"),
+);
+
+export const proactiveShipLink = v.object({
+ prNumber: v.number(),
+ sha: v.string(),
+ releaseTag: v.optional(v.string()),
+ mergedAt: v.number(),
+ url: v.string(),
+});
+
+export const proactiveProof = v.object({
+ people: v.number(),
+ payingPeople: v.number(),
+ sources: v.array(
+ v.object({
+ channel: proactiveSourceChannelValue,
+ count: v.number(),
+ }),
+ ),
+ strength: proactiveStrengthValue,
+ growthPerWeek: v.number(),
+});
+
+export const proactiveQuote = v.object({
+ text: v.string(),
+ author: v.string(),
+ channel: proactiveSourceChannelValue,
+ url: v.string(),
+});
+
+export const proactiveGhost = v.object({
+ id: v.string(),
+ workspaceId: v.string(),
+ title: v.string(),
+ status: proactiveStatusValue,
+ proof: proactiveProof,
+ sampleQuotes: v.array(proactiveQuote),
+ firstSeen: v.number(),
+ lastSeen: v.number(),
+});
+
+export const proactiveEvidence = v.object({
+ id: v.string(),
+ sourceChannel: proactiveSourceChannelValue,
+ author: v.string(),
+ text: v.string(),
+ url: v.string(),
+ confidenceBucket: proactiveConfidenceBucketValue,
+ promotedBy: proactivePromotedByValue,
+});
+
+export const proactiveNeed = v.object({
+ id: v.string(),
+ workspaceId: v.string(),
+ title: v.string(),
+ status: v.literal("accepted"),
+ proof: proactiveProof,
+ sampleQuotes: v.array(proactiveQuote),
+ firstSeen: v.number(),
+ lastSeen: v.number(),
+ evidence: v.array(proactiveEvidence),
+ linkedShip: v.optional(proactiveShipLink),
+});
+
+export const proactiveDraftProposal = v.object({
+ id: v.string(),
+ kind: proactiveDraftKindValue,
+ needId: v.string(),
+ needTitle: v.string(),
+ draftText: v.string(),
+ recipients: v.optional(
+ v.array(
+ v.object({
+ handle: v.string(),
+ channel: v.string(),
+ }),
+ ),
+ ),
+ status: proactiveDraftStatusValue,
+});
+
+export const proactiveChangelogEntry = v.object({
+ id: v.string(),
+ title: v.string(),
+ body: v.string(),
+ shippedAt: v.number(),
+ ship: proactiveShipLink,
+ published: v.boolean(),
+});
+
+export const proactiveMemoryRule = v.object({
+ id: v.string(),
+ kind: proactiveMemoryKindValue,
+ text: v.string(),
+ taughtBy: v.string(),
+ taughtAt: v.number(),
+ blastRadius: v.number(),
+ enabled: v.boolean(),
+});
+
+export const proactiveDigestPreview = v.object({
+ period: v.object({ from: v.number(), to: v.number() }),
+ resolved: v.array(
+ v.object({
+ needTitle: v.string(),
+ ship: proactiveShipLink,
+ peopleNotified: v.number(),
+ }),
+ ),
+ readyGhosts: v.array(proactiveGhost),
+ handledSilently: v.number(),
+});
+
+export const okResult = v.object({ ok: v.literal(true) });
diff --git a/packages/backend/convex/schemaProactiveTables.ts b/packages/backend/convex/schemaProactiveTables.ts
new file mode 100644
index 0000000..bbccc3e
--- /dev/null
+++ b/packages/backend/convex/schemaProactiveTables.ts
@@ -0,0 +1,184 @@
+import { defineTable } from "convex/server";
+import { v } from "convex/values";
+
+import {
+ proactiveConfidenceBucketValue,
+ proactiveDraftKindValue,
+ proactiveDraftStatusValue,
+ proactiveMemoryKindValue,
+ proactivePromotedByValue,
+ proactiveSourceChannelValue,
+ proactiveStatusValue,
+ proactiveStrengthValue,
+} from "./proactiveValidators";
+
+export const proactiveTables = {
+ needs: defineTable({
+ workspaceId: v.id("workspaces"),
+ title: v.string(),
+ status: proactiveStatusValue,
+ proofPeople: v.number(),
+ proofPayingPeople: v.number(),
+ proofSources: v.array(
+ v.object({
+ channel: proactiveSourceChannelValue,
+ count: v.number(),
+ }),
+ ),
+ proofStrength: proactiveStrengthValue,
+ proofGrowthPerWeek: v.number(),
+ sampleQuotes: v.array(
+ v.object({
+ text: v.string(),
+ author: v.string(),
+ channel: proactiveSourceChannelValue,
+ url: v.string(),
+ }),
+ ),
+ firstSeen: v.number(),
+ lastSeen: v.number(),
+ linkedShip: v.optional(
+ v.object({
+ prNumber: v.number(),
+ sha: v.string(),
+ releaseTag: v.optional(v.string()),
+ mergedAt: v.number(),
+ url: v.string(),
+ }),
+ ),
+ conditionFlags: v.object({
+ readyForReview: v.boolean(),
+ hasLinkedShip: v.boolean(),
+ digestEligible: v.boolean(),
+ }),
+ clusterKey: v.string(),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_workspace_and_status", ["workspaceId", "status"])
+ .index("by_workspace_and_clusterKey", ["workspaceId", "clusterKey"])
+ .index("by_workspace_and_lastSeen", ["workspaceId", "lastSeen"]),
+
+ evidence: defineTable({
+ workspaceId: v.id("workspaces"),
+ needId: v.id("needs"),
+ sourceEventId: v.optional(v.id("sourceEvents")),
+ personId: v.optional(v.id("persons")),
+ sourceChannel: proactiveSourceChannelValue,
+ author: v.string(),
+ handle: v.optional(v.string()),
+ text: v.string(),
+ url: v.string(),
+ confidenceBucket: proactiveConfidenceBucketValue,
+ promotedBy: proactivePromotedByValue,
+ createdAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_need", ["needId"])
+ .index("by_sourceEvent", ["sourceEventId"])
+ .index("by_person", ["personId"]),
+
+ needVectors: defineTable({
+ workspaceId: v.id("workspaces"),
+ needId: v.id("needs"),
+ embedding: v.array(v.float64()),
+ model: v.string(),
+ textHash: v.string(),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_need", ["needId"])
+ .vectorIndex("by_embedding", {
+ vectorField: "embedding",
+ dimensions: 1536,
+ filterFields: ["workspaceId"],
+ }),
+
+ persons: defineTable({
+ workspaceId: v.id("workspaces"),
+ displayName: v.string(),
+ verifiedEmail: v.optional(v.string()),
+ accountId: v.optional(v.string()),
+ paying: v.boolean(),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_workspace_and_email", ["workspaceId", "verifiedEmail"])
+ .index("by_workspace_and_accountId", ["workspaceId", "accountId"]),
+
+ identityHandles: defineTable({
+ workspaceId: v.id("workspaces"),
+ personId: v.id("persons"),
+ provider: proactiveSourceChannelValue,
+ handle: v.string(),
+ verified: v.boolean(),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_person", ["personId"])
+ .index("by_workspace_and_provider_and_handle", ["workspaceId", "provider", "handle"]),
+
+ draftProposals: defineTable({
+ workspaceId: v.id("workspaces"),
+ kind: proactiveDraftKindValue,
+ needId: v.id("needs"),
+ needTitle: v.string(),
+ draftText: v.string(),
+ recipients: v.optional(
+ v.array(
+ v.object({
+ handle: v.string(),
+ channel: v.string(),
+ }),
+ ),
+ ),
+ status: proactiveDraftStatusValue,
+ approvedAt: v.optional(v.number()),
+ rejectedAt: v.optional(v.number()),
+ edits: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_workspace_and_status", ["workspaceId", "status"])
+ .index("by_need", ["needId"]),
+
+ memoryRules: defineTable({
+ workspaceId: v.id("workspaces"),
+ kind: proactiveMemoryKindValue,
+ text: v.string(),
+ taughtBy: v.string(),
+ taughtAt: v.number(),
+ blastRadius: v.number(),
+ enabled: v.boolean(),
+ undoneAt: v.optional(v.number()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_workspace_and_enabled", ["workspaceId", "enabled"])
+ .index("by_workspace_and_kind", ["workspaceId", "kind"]),
+
+ proactivePipelineEvents: defineTable({
+ workspaceId: v.id("workspaces"),
+ sourceEventId: v.optional(v.id("sourceEvents")),
+ externalId: v.string(),
+ state: v.union(
+ v.literal("queued"),
+ v.literal("processed"),
+ v.literal("suppressed"),
+ v.literal("failed"),
+ ),
+ dedupeKey: v.string(),
+ lastError: v.optional(v.string()),
+ createdAt: v.number(),
+ updatedAt: v.number(),
+ })
+ .index("by_workspace", ["workspaceId"])
+ .index("by_workspace_and_dedupeKey", ["workspaceId", "dedupeKey"])
+ .index("by_workspace_and_state", ["workspaceId", "state"]),
+};
diff --git a/packages/backend/convex/schemaWorkspaceTables.ts b/packages/backend/convex/schemaWorkspaceTables.ts
index 68aa061..336b160 100644
--- a/packages/backend/convex/schemaWorkspaceTables.ts
+++ b/packages/backend/convex/schemaWorkspaceTables.ts
@@ -1,6 +1,7 @@
import { workspaceAutomationTables } from "./schemaWorkspaceAutomationTables";
import { workspaceCoreTables } from "./schemaWorkspaceCoreTables";
import { workspaceIntegrationTables } from "./schemaWorkspaceIntegrationTables";
+import { proactiveTables } from "./schemaProactiveTables";
import { workspaceSourceTables } from "./schemaWorkspaceSourceTables";
export const workspaceSchemaTables = {
@@ -8,4 +9,5 @@ export const workspaceSchemaTables = {
...workspaceSourceTables,
...workspaceAutomationTables,
...workspaceIntegrationTables,
+ ...proactiveTables,
};
diff --git a/packages/backend/convex/sources.ts b/packages/backend/convex/sources.ts
new file mode 100644
index 0000000..0f269a7
--- /dev/null
+++ b/packages/backend/convex/sources.ts
@@ -0,0 +1,41 @@
+import { internal } from "./_generated/api";
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+
+import { connectGithubArgs, proactiveWorkspaceArgs } from "./proactiveArgs";
+import { okResult } from "./proactiveValidators";
+import { ensureGitHubConnection } from "./amendSeed";
+import { requireProactiveWorkspace } from "./proactiveShared";
+
+export const status = query({
+ args: proactiveWorkspaceArgs,
+ returns: v.object({ github: v.boolean(), feedback: v.boolean() }),
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const github = await ctx.db
+ .query("githubConnections")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .first();
+ const feedback = await ctx.db
+ .query("sourceEvents")
+ .withIndex("by_workspace", (q) => q.eq("workspaceId", workspace._id))
+ .first();
+ return { github: Boolean(github), feedback: Boolean(feedback) };
+ },
+});
+
+export const connectGithub = mutation({
+ args: connectGithubArgs,
+ returns: okResult,
+ handler: async (ctx, args) => {
+ const workspace = await requireProactiveWorkspace(ctx, args);
+ const [owner, repo] = args.repo.replace(/^https:\/\/github\.com\//, "").split("/");
+ const connection = await ensureGitHubConnection(ctx, workspace._id, owner || undefined, repo || args.repo);
+ await ctx.scheduler.runAfter(0, internal.backfill.gitHistory, {
+ workspaceId: workspace._id,
+ owner: connection.owner,
+ repo: connection.repo,
+ });
+ return { ok: true as const };
+ },
+});
diff --git a/packages/backend/tests/draftSendGate.test.ts b/packages/backend/tests/draftSendGate.test.ts
new file mode 100644
index 0000000..61d1287
--- /dev/null
+++ b/packages/backend/tests/draftSendGate.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, test } from "bun:test";
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+
+const convexDir = join(import.meta.dir, "../convex");
+
+function tsFiles(dir: string): string[] {
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+ const path = join(dir, entry.name);
+ if (entry.isDirectory()) return entry.name === "_generated" ? [] : tsFiles(path);
+ return entry.isFile() && entry.name.endsWith(".ts") ? [path] : [];
+ });
+}
+
+describe("external send gate", () => {
+ test("transactional email sender is isolated from proactive drafts", () => {
+ const offenders = tsFiles(convexDir).filter((file) => {
+ const source = readFileSync(file, "utf8");
+ return (
+ source.includes("sendTransactionalEmail(") &&
+ !file.endsWith("amendTransactionalEmails.ts") &&
+ !file.endsWith("httpRuntimeDeliveries.ts") &&
+ !file.endsWith("draftSendGate.test.ts")
+ );
+ });
+
+ expect(offenders).toEqual([]);
+ });
+
+ test("proactive delivery enqueue is gated by drafts.approve", () => {
+ const source = readFileSync(join(convexDir, "drafts.ts"), "utf8");
+ expect(source).toContain("export const approve");
+ expect(source).toContain("queueNotificationDeliveries");
+ expect(source).toContain("gatedBy: \"drafts.approve\"");
+ });
+});
diff --git a/packages/backend/tests/proactiveClassifier.test.ts b/packages/backend/tests/proactiveClassifier.test.ts
new file mode 100644
index 0000000..50b2069
--- /dev/null
+++ b/packages/backend/tests/proactiveClassifier.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "bun:test";
+
+import { classifySignal, facetsCompatible } from "../convex/proactiveClassifier";
+
+describe("proactive facet guard", () => {
+ test("keeps export and import requests split", () => {
+ const exportSignal = classifySignal({
+ channel: "support",
+ title: "Need CSV export",
+ text: "Please let us export tagged feedback to CSV",
+ });
+ const importSignal = classifySignal({
+ channel: "support",
+ title: "Need CSV import",
+ text: "Please let us import old feedback from CSV",
+ });
+
+ expect(exportSignal.area).toBe("export");
+ expect(importSignal.area).toBe("import");
+ expect(facetsCompatible(exportSignal, importSignal)).toBe(false);
+ });
+
+ test("merges paraphrases of the same notification need", () => {
+ const first = classifySignal({
+ channel: "github",
+ text: "Notify users when a thing they asked for ships",
+ });
+ const second = classifySignal({
+ channel: "embed",
+ text: "Send an email alert after requested work is shipped",
+ });
+
+ expect(first.area).toBe("notification");
+ expect(second.area).toBe("notification");
+ expect(facetsCompatible(first, second)).toBe(true);
+ });
+});
From 58eb22a715b8a4b11492ba23bbaea25877d15747 Mon Sep 17 00:00:00 2001
From: Leo
Date: Sat, 20 Jun 2026 10:50:48 -0400
Subject: [PATCH 3/7] update
---
AGENTS.md | 11 +
apps/web/package.json | 5 +-
apps/web/public/fonts/inter-400.woff2 | Bin 0 -> 23664 bytes
apps/web/public/fonts/inter-500.woff2 | Bin 0 -> 24272 bytes
apps/web/public/fonts/inter-LICENSE.txt | 93 +++
.../web/src/components/amend-agent-chrome.tsx | 32 +-
.../web/src/components/amend-board-screen.tsx | 410 --------------
.../components/amend-connections-screen.tsx | 422 ++++++++++++++
.../components/amend-dashboard-constants.ts | 4 +-
.../amend-dashboard-content-record-types.ts | 6 +
.../amend-dashboard-content-types.ts | 13 +
.../components/amend-dashboard-content.tsx | 49 +-
.../components/amend-dashboard-core-types.ts | 12 +-
.../src/components/amend-dashboard-data.tsx | 11 +
.../amend-dashboard-detail-router.tsx | 17 +-
.../amend-dashboard-main-workspace.tsx | 263 +++++----
.../src/components/amend-dashboard-shared.tsx | 58 +-
.../amend-dashboard-status-utils.ts | 6 +-
.../src/components/amend-dashboard-status.tsx | 8 +-
.../amend-dashboard-sync-mappers.ts | 3 +
apps/web/src/components/amend-dashboard.tsx | 2 +-
.../src/components/amend-drafts-screen.tsx | 219 --------
.../web/src/components/amend-inbox-screen.tsx | 530 ++++++++++++++++++
.../src/components/amend-memory-screen.tsx | 333 +++++------
.../components/amend-need-detail-screen.tsx | 16 +-
.../components/analytics-workspace-panels.tsx | 2 +-
.../src/components/analytics-workspace.tsx | 2 +-
apps/web/src/components/app-toaster.tsx | 22 +-
apps/web/src/components/brand-icons.tsx | 71 +++
.../components/changelog-editor-header.tsx | 258 +++++----
.../src/components/changelog-editor-main.tsx | 79 +--
.../components/changelog-editor-sidebar.tsx | 126 -----
.../src/components/changelog-editor-types.ts | 19 +-
.../components/changelog-editor-workspace.tsx | 376 ++++++++++---
.../components/changelog-publish-review.tsx | 389 +++++++++++++
.../src/components/changelog-tag-input.tsx | 248 ++++++++
apps/web/src/components/changelog-tags.ts | 88 +++
.../components/dashboard-detail-shared.tsx | 17 +-
apps/web/src/components/dashboard-header.tsx | 258 ++++-----
.../dashboard-onboarding-checklist.tsx | 210 -------
.../dashboard-onboarding-launcher.tsx | 252 +++++++++
.../components/dashboard-sidebar-chrome.tsx | 167 ++++--
.../src/components/dashboard-sort-menu.tsx | 60 ++
apps/web/src/components/dashboard-sort.ts | 81 +++
apps/web/src/components/dashboard-toolbar.tsx | 18 +-
.../src/components/dashboard-user-menu.tsx | 10 +-
.../web/src/components/dashboard-view-nav.tsx | 10 +-
.../dashboard-workspace-switcher.tsx | 157 ++++--
.../src/components/feedback-detail-header.tsx | 38 --
.../components/feedback-detail-sidebar.tsx | 21 -
.../components/feedback-detail-workspace.tsx | 128 ++---
apps/web/src/components/onboarding-flow.tsx | 124 ++--
apps/web/src/components/onboarding-layout.tsx | 49 ++
.../components/onboarding-showcase-panel.tsx | 55 --
apps/web/src/components/onboarding-ui.tsx | 114 ++++
.../components/onboarding-welcome-panel.tsx | 42 --
.../src/components/portal-account-actions.tsx | 82 ---
.../portal-feedback-submission-panel.tsx | 130 -----
.../src/components/portal-list-elements.tsx | 47 --
apps/web/src/components/posts-workspace.tsx | 90 ++-
.../components/project-setup-details-form.tsx | 134 -----
.../project-setup-first-run-panel.tsx | 199 +++----
...project-setup-github-repository-picker.tsx | 65 ++-
.../src/components/project-setup-panels.tsx | 3 -
.../project-setup-preview-panel.tsx | 49 --
...roject-setup-repository-directory-list.tsx | 69 ++-
.../src/components/project-setup-shell.tsx | 56 +-
.../project-setup-source-picker.tsx | 80 ++-
.../components/project-setup-step-status.tsx | 64 ++-
.../components/project-setup-workspace.tsx | 103 ----
.../components/public-portal-changelog.tsx | 157 ++++++
.../src/components/public-portal-compose.tsx | 190 +++++++
.../public-portal-feedback-section.tsx | 83 ---
.../src/components/public-portal-feedback.tsx | 314 +++++++++++
.../src/components/public-portal-header.tsx | 51 --
.../web/src/components/public-portal-hero.tsx | 22 -
.../web/src/components/public-portal-rail.tsx | 103 ----
.../public-portal-roadmap-updates.tsx | 90 ---
.../src/components/public-portal-roadmap.tsx | 154 +++++
.../src/components/public-portal-shared.tsx | 113 ++++
.../src/components/public-portal-topnav.tsx | 149 +++++
.../web/src/components/public-portal-types.ts | 19 +
.../web/src/components/public-portal-view.tsx | 154 +++--
apps/web/src/components/roadmap-card.tsx | 223 +++++---
apps/web/src/components/roadmap-column.tsx | 82 +--
.../components/roadmap-detail-workspace.tsx | 140 ++---
apps/web/src/components/roadmap-workspace.tsx | 150 +++--
.../settings-workspace-accounts-panel.tsx | 80 ++-
.../settings-workspace-automation-panel.tsx | 85 +--
.../settings-workspace-controller-state.ts | 64 ++-
.../settings-workspace-general-panel.tsx | 438 +++++++++++----
.../components/settings-workspace-header.tsx | 39 --
.../settings-workspace-panel-primitives.tsx | 184 +++++-
.../settings-workspace-panel-types.ts | 9 +-
.../components/settings-workspace-panels.tsx | 34 +-
.../settings-workspace-portal-panel.tsx | 70 +--
.../components/settings-workspace-runtime.ts | 8 +
.../settings-workspace-services-panel.tsx | 72 ++-
.../components/settings-workspace-sidebar.tsx | 46 --
.../settings-workspace-tags-panel.tsx | 256 +++++++++
.../components/settings-workspace-toolbar.tsx | 93 +++
.../web/src/components/settings-workspace.tsx | 182 ++----
apps/web/src/components/sign-in-form.tsx | 1 -
apps/web/src/components/sign-up-form.tsx | 1 -
apps/web/src/components/status-pill.tsx | 46 ++
.../components/use-amend-dashboard-actions.ts | 5 +-
.../use-amend-dashboard-active-project.ts | 26 +
.../use-amend-dashboard-controller.ts | 65 ++-
.../components/use-amend-dashboard-model.ts | 22 +-
.../use-amend-dashboard-navigation.ts | 19 +-
.../use-amend-dashboard-redirects.ts | 11 +-
.../components/use-amend-dashboard-route.ts | 56 +-
.../use-amend-dashboard-save-actions.ts | 73 ++-
.../use-amend-dashboard-ui-state.ts | 9 -
.../components/use-changelog-cover-upload.ts | 61 ++
.../src/components/use-settings-autosave.ts | 126 +++++
.../use-settings-workspace-controller.ts | 60 +-
.../use-settings-workspace-save-actions.ts | 112 ++--
apps/web/src/components/vote-button.tsx | 118 ++++
apps/web/src/index.css | 25 +-
apps/web/src/lib/icons.tsx | 8 +
apps/web/src/lib/mock-amend.ts | 8 +
apps/web/src/lib/sanitize-portal-html.ts | 33 ++
apps/web/src/lib/seo.ts | 22 +
apps/web/src/lib/toast.ts | 63 +--
apps/web/src/lib/use-portal-voter.ts | 102 ++++
apps/web/src/routes/dashboard.$view.tsx | 14 +-
apps/web/src/routes/dashboard.tsx | 7 +-
apps/web/src/routes/portal.$workspaceSlug.tsx | 20 +-
apps/web/vite.config.ts | 2 +-
bun.lock | 24 +-
packages/backend/convex/_generated/api.d.ts | 4 +
packages/backend/convex/amend.ts | 7 +
.../backend/convex/amendAnalyticsEvents.ts | 2 +
.../convex/amendContentFunctionArgs.ts | 15 +
.../convex/amendContentMutationHandlers.ts | 134 ++++-
.../backend/convex/amendDashboardOverview.ts | 54 +-
.../amendFeedbackCreateMutationHandler.ts | 12 +-
...mendFeedbackInteractionMutationHandlers.ts | 12 +-
.../amendMutationFunctionDefinitions.ts | 20 +
.../backend/convex/amendPortalReadHandlers.ts | 98 +++-
packages/backend/convex/amendSeed.ts | 23 +
packages/backend/convex/amendValidators.ts | 2 +
packages/backend/convex/changelog.ts | 20 +
packages/backend/convex/changelogScheduler.ts | 52 ++
packages/backend/convex/crons.ts | 7 +
packages/backend/convex/drafts.ts | 23 +-
packages/backend/convex/needs.ts | 33 +-
packages/backend/convex/proactiveArgs.ts | 5 +
packages/backend/convex/proactiveShared.ts | 2 -
.../backend/convex/proactiveValidators.ts | 20 +-
.../backend/convex/schemaProactiveTables.ts | 1 +
.../convex/schemaProductContentTables.ts | 15 +-
.../convex/schemaWorkspaceCoreTables.ts | 4 +-
packages/backend/convex/sources.ts | 49 +-
packages/backend/convex/tags.ts | 158 ++++++
packages/ui/package.json | 3 +-
packages/ui/src/components/checkbox.tsx | 4 +-
packages/ui/src/components/dropdown-menu.tsx | 8 +-
packages/ui/src/components/icons.tsx | 35 ++
packages/ui/src/components/sonner.tsx | 24 +-
packages/ui/src/styles/theme.css | 36 ++
162 files changed, 8594 insertions(+), 4466 deletions(-)
create mode 100644 apps/web/public/fonts/inter-400.woff2
create mode 100644 apps/web/public/fonts/inter-500.woff2
create mode 100644 apps/web/public/fonts/inter-LICENSE.txt
delete mode 100644 apps/web/src/components/amend-board-screen.tsx
create mode 100644 apps/web/src/components/amend-connections-screen.tsx
delete mode 100644 apps/web/src/components/amend-drafts-screen.tsx
create mode 100644 apps/web/src/components/amend-inbox-screen.tsx
create mode 100644 apps/web/src/components/brand-icons.tsx
delete mode 100644 apps/web/src/components/changelog-editor-sidebar.tsx
create mode 100644 apps/web/src/components/changelog-publish-review.tsx
create mode 100644 apps/web/src/components/changelog-tag-input.tsx
create mode 100644 apps/web/src/components/changelog-tags.ts
delete mode 100644 apps/web/src/components/dashboard-onboarding-checklist.tsx
create mode 100644 apps/web/src/components/dashboard-onboarding-launcher.tsx
create mode 100644 apps/web/src/components/dashboard-sort-menu.tsx
create mode 100644 apps/web/src/components/dashboard-sort.ts
delete mode 100644 apps/web/src/components/feedback-detail-header.tsx
delete mode 100644 apps/web/src/components/feedback-detail-sidebar.tsx
create mode 100644 apps/web/src/components/onboarding-layout.tsx
delete mode 100644 apps/web/src/components/onboarding-showcase-panel.tsx
create mode 100644 apps/web/src/components/onboarding-ui.tsx
delete mode 100644 apps/web/src/components/onboarding-welcome-panel.tsx
delete mode 100644 apps/web/src/components/portal-account-actions.tsx
delete mode 100644 apps/web/src/components/portal-feedback-submission-panel.tsx
delete mode 100644 apps/web/src/components/portal-list-elements.tsx
delete mode 100644 apps/web/src/components/project-setup-details-form.tsx
delete mode 100644 apps/web/src/components/project-setup-panels.tsx
delete mode 100644 apps/web/src/components/project-setup-preview-panel.tsx
delete mode 100644 apps/web/src/components/project-setup-workspace.tsx
create mode 100644 apps/web/src/components/public-portal-changelog.tsx
create mode 100644 apps/web/src/components/public-portal-compose.tsx
delete mode 100644 apps/web/src/components/public-portal-feedback-section.tsx
create mode 100644 apps/web/src/components/public-portal-feedback.tsx
delete mode 100644 apps/web/src/components/public-portal-header.tsx
delete mode 100644 apps/web/src/components/public-portal-hero.tsx
delete mode 100644 apps/web/src/components/public-portal-rail.tsx
delete mode 100644 apps/web/src/components/public-portal-roadmap-updates.tsx
create mode 100644 apps/web/src/components/public-portal-roadmap.tsx
create mode 100644 apps/web/src/components/public-portal-shared.tsx
create mode 100644 apps/web/src/components/public-portal-topnav.tsx
delete mode 100644 apps/web/src/components/settings-workspace-header.tsx
create mode 100644 apps/web/src/components/settings-workspace-runtime.ts
delete mode 100644 apps/web/src/components/settings-workspace-sidebar.tsx
create mode 100644 apps/web/src/components/settings-workspace-tags-panel.tsx
create mode 100644 apps/web/src/components/settings-workspace-toolbar.tsx
create mode 100644 apps/web/src/components/status-pill.tsx
create mode 100644 apps/web/src/components/use-amend-dashboard-active-project.ts
create mode 100644 apps/web/src/components/use-changelog-cover-upload.ts
create mode 100644 apps/web/src/components/use-settings-autosave.ts
create mode 100644 apps/web/src/components/vote-button.tsx
create mode 100644 apps/web/src/lib/sanitize-portal-html.ts
create mode 100644 apps/web/src/lib/use-portal-voter.ts
create mode 100644 packages/backend/convex/changelogScheduler.ts
create mode 100644 packages/backend/convex/tags.ts
create mode 100644 packages/ui/src/components/icons.tsx
diff --git a/AGENTS.md b/AGENTS.md
index 2d5ec33..833c870 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,12 @@
# Agent Guidelines — Amend
+## Vocab
+
+Shorthand for dashboard UI regions (React DevTools component → the name the user says):
+
+- **Page content** — `DashboardWorkspaceSurface` (`dashboard-workspace-surface.tsx`): the rounded, scrollable main content region rendered inside every dashboard view. It's on every page — when the user says "page content," this is it.
+- **Toolbar** — `ToolbarBar` (`dashboard-toolbar.tsx`) and its per-view wrappers (`RoadmapToolbar` / `FeedbackToolbar` / `ChangelogToolbar`): the full-width sub-nav/filter bar directly under the dashboard header.
+
## UI Interaction Animations
**No scale transforms on interactive elements.** Do not use `active:scale-[0.96]`, `hover:scale-*`, or any geometry-changing transform on buttons, links, sidebar items, or nav elements. They feel jittery and distracting.
@@ -16,6 +23,10 @@
**Premium shell direction:** Amend should keep its own source-linked identity, but use a Tripwire-like level of refinement: near-black background, raised rounded app surfaces, soft borders, compact sans-serif body text, mono only for data/code, and small high-signal accents. Do not copy Tripwire one-for-one.
+## Icons
+
+We use **Hugeicons only** — never import `lucide-react` (it is not a dependency). Import icons from the lucide-compatible wrapper: `@/lib/icons` in `apps/web`, `@amend/ui/components/icons` in `packages/ui`. Add a new glyph by mapping a Hugeicons `*Icon` export to a lucide-style name inside the wrapper.
+
## Charting
Do not install `recharts` — it has CJS/ESM interop issues with this Vite + TanStack Start setup (causes `require_isUnsafeProperty is not a function` at runtime). Use pure CSS/SVG bar charts instead (see `proactivation-analytics-panel.tsx` for the pattern: flex columns with `height: pct%` + `bg-foreground`).
diff --git a/apps/web/package.json b/apps/web/package.json
index 738735e..4f197c0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -15,6 +15,7 @@
"@amend/ui": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@convex-dev/react-query": "^0.1.0",
+ "@dnd-kit/core": "^6.3.1",
"@hugeicons/core-free-icons": "^4.2.0",
"@hugeicons/react": "^1.1.6",
"@posthog/cli": "^0.7.22",
@@ -30,14 +31,14 @@
"convex": "catalog:",
"dotenv": "catalog:",
"gsap": "^3.15.0",
- "lucide-react": "^1.18.0",
"next-themes": "catalog:",
"posthog-js": "^1.386.6",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-grab": "^0.1.44",
"satori": "^0.26.0",
- "sileo": "^0.1.5",
+ "simple-icons": "^16.23.0",
+ "sonner": "^2.0.5",
"tailwindcss": "^4.3.1",
"zod": "catalog:"
},
diff --git a/apps/web/public/fonts/inter-400.woff2 b/apps/web/public/fonts/inter-400.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..f15b025d666269fb07daf0263e54231481e4289c
GIT binary patch
literal 23664
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
literal 0
HcmV?d00001
diff --git a/apps/web/public/fonts/inter-500.woff2 b/apps/web/public/fonts/inter-500.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..54f0a595dd3c76344a980109e7517ed42416de93
GIT binary patch
literal 24272
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