Repository navigation
Conversation
Adds `packages/ai` — the only package in the tree that talks to a model. The
composer writes the message body for a recommendation, and everything around
it exists to make that output safe to show a reviewer.
Two properties are structural rather than requested:
1. It can only see grounded inputs. The prompt is assembled from stored
evidence, stored facts and the customer's own offering. There is no path
by which the model learns something it may not cite.
2. Its output is checked, not trusted. Eight deterministic §14.2 gates run
on every draft — grounding overlap, unsupported claims, identity
confidence, flattery, spam patterns, cross-prospect duplication,
sensitive topics, policy. A draft that fails is rewritten once, naming
the exact invented fragments; still failing, it is withheld.
Withheld is a working state, not an error. The card keeps the prospect, the
evidence and the recommended action, and the reviewer writes the message. A
bad draft next to a caveat is still a bad draft someone might approve.
The undecidable question "is this claim true?" is inverted into a decidable
one: does every specific assertion — quoted phrase, number, mid-sentence
proper noun — appear in stored evidence? That is what `checks.ts` answers,
and it is why no model output reaches a human unexamined.
Also:
- `POST /recommendations/:id/draft` composes or recomposes on demand, returns
200 with `drafted: false` and a reason when withholding, and audits it.
A user-edited draft is never discarded by a recompose.
- The approval card gains "Write draft" / "Rewrite", and states plainly why
no draft exists rather than offering a retry that cannot help.
- Prompt caching splits the stable offering/voice prefix from the
per-prospect half, so a campaign pays for the prefix once.
- The composer is optional infrastructure: without ANTHROPIC_API_KEY the
queue still runs end to end and drafting returns 503. Refusing to boot
would take the system down for a feature designed to produce nothing.
No model decides a merge, a score, or a policy outcome. 367 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fresh login was an empty app with no way to fill it. The cause was not a missing screen: registration created a user, organisation and workspace, but no offering and no campaign — and a campaign requires an offering, a prospect requires a campaign. There was no route that could add a person at all. The pipeline had only ever been driven from a local script. - Registration now provisions a starter offering and campaign. Existing accounts are backfilled on first use rather than told to create something the UI does not expose. - POST /api/v1/prospects runs the full chain for a GitHub handle. It is synchronous because a first-run user needs to see something appear, and it reports identities linked and signals found rather than "queued". A handle that resolves to nothing answers 200 with a reason: a typo is information, not a server fault. - GET /api/v1/people backs a real prospect list and detail page, so the evidence behind a recommendation is inspectable. - Today's empty state now distinguishes "no prospects yet" from "no recommendations yet" and offers the action that fixes the first. The pipeline moves from apps/worker to packages/pipeline. Both the API and the background loop run it, and an app importing another app's source made the dependency direction a lie. apps/worker held nothing else, so it is gone. Email verification ships alongside it. Format was already checked; what was missing was any proof the address exists. Tokens are stored as a SHA-256 digest like sessions, superseded rather than accumulated on resend, and every failure answers identically so a guessed token learns nothing. The gate is on sending, not on signing in — research and drafting still work while the mail is in flight. Accounts predating this are grandfathered, and a send failure never fails the signup that triggered it. Resend is reached over plain HTTP; with no key the link is logged instead, so a fresh checkout still completes a signup. 395 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The marketing page rendered inside the signed-in app shell: capped at max-w-2xl and sitting under the bottom tab bar, so a stranger's first impression of the product was a 672px strip beneath a mobile nav. Splitting the shells is most of the fix. - Route groups: (app) keeps the narrow column and the bottom nav, (marketing) gets full bleed and no chrome. URLs are unchanged — route groups do not appear in paths — and login and verify move to (marketing), which is where they always belonged. - The root layout now holds only what every route needs. - The landing page leads with the product's actual output: an approval card with a real quote, a source link and the recommended action. Showing the artifact argues the case better than describing it. Evidence and refusal sections follow, then the CTA. - Type is Schibsted Grotesk and IBM Plex Mono via next/font, self-hosted at build time so there is no blocking request to Google and no layout shift. The font variables are deliberately not named --font-sans/--font-mono. next/font sets its variable on <html>, which is also :root, so reusing the theme's own names makes `--font-sans: var(--font-sans), …` a self-reference on a single element — an invalid cycle that silently drops the face. Caught by reading the built stylesheet, not by the build. Verified by running the production build and requesting the pages: landing, login, verify and offline all 200 with content, no app chrome on any of them, and the font chain resolves --font-schibsted -> --font-sans -> --default-font-family with no cycle. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
|
Superseded by a branch cut cleanly from main — the original branched off the pre-squash head and conflicted. |
# Conflicts: # apps/web/app/(app)/today/page.tsx # apps/web/app/prospects/page.tsx
Contributor
Author
|
Superseded by #13 — reopened briefly by automation to diagnose a stale head ref, closing again. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships direction 1a from the design options.
The structural bug behind "bad UI"
The root layout wrapped every route — including the public landing page — in the signed-in app shell:
max-w-2xl(672px) under a fixed bottom tab bar. A stranger's first impression of the product was a narrow strip beneath a mobile app nav. No amount of restyling fixes that; the shell was wrong.(app)keeps the narrow column andBottomNav;(marketing)is full-bleed with no chrome. URLs are unchanged — route groups don't appear in paths.loginandverifymove to(marketing), where they always belonged — they had a bottom app-nav before.The page
Leads with the product's actual output rather than a description of it: an approval card with a real quote, a source link, the recommended action and the approve/edit/skip row. Then the evidence trail (identities with confidence, and why
name + citynever merges), the refusal (409 policy_denied, gate named), and the CTA.Type is Schibsted Grotesk + IBM Plex Mono via
next/font, self-hosted at build time — no blocking request to Google, no layout shift. Deliberately not Inter/Roboto. Palette is unchanged from the app's existing tokens so marketing and product don't look like different companies.One bug worth calling out
The font variables are not named
--font-sans/--font-mono.next/fontsets its variable on<html>, which is also:root, so reusing the theme's own names makes--font-sans: var(--font-sans), …a self-reference on a single element — an invalid cycle that silently drops the face. Caught by reading the built stylesheet; the build passed either way.Verification
bun run check— format, typecheck, 395 tests, all passing/200 (26KB),/login200,/verify200,/offline200max-w-2xland no bottom nav in the marketing HTML--font-schibsted→--font-sans→--default-font-familywith no cycle🤖 Generated with Claude Code