Compare commits

..

32 Commits

Author SHA1 Message Date
59485579ec clean: remove .pi/ config from calvana repo — lives in pi-vs-claude-code now
- Removed all .pi/ (agents, themes, extensions, skills, observatory)
- Removed CLAUDE.md (belongs in parent pi repo)
- Added .pi/ and CLAUDE.md to .gitignore
- Added .pi/ to pledge-now-pay-later/.gitignore
2026-03-04 17:32:20 +08:00
ef37ca0c18 Fix payment flexibility quote length and orphan word
- Shorten quote 03 from 'Can I split it across a few months?' to 'Can I pay monthly?' for column symmetry
- Add nbsp between 'money' and 'arriving' to prevent orphan line break
2026-03-04 13:58:42 +08:00
6b71fa227b fix stacking cards: flatMap siblings, no wrappers, no shadow, z < header
ROOT CAUSE: each card was wrapped in its own div (min-h-[85vh]),
scoping sticky to that wrapper — cards could NEVER overlap.

FIX: flatMap returns all sticky divs + h-4 spacers as direct
siblings under the same parent (mt-14). Sticky now works
correctly — each card overlaps the previous with 20px peek.

- Removed all shadows (border-gray-100 only)
- z-index: 1-4 (was 10-40, conflicting with nav z-40)
- top: 72/92/112/132px (20px stagger)
- h-4 spacers between cards (no big white gaps)
- Regenerated dinner image: dark navy table, candlelight, £5,000
  pledge card — zero white space (was white tablecloth)
2026-03-03 23:29:26 +08:00
dc1253af33 sticky stacking cards on scroll + kill border white space
STACKING EFFECT:
- Cards use position: sticky with increasing top offset (72-120px)
- z-index layering (10-40) so later cards stack on top
- pb-36 between cards for scroll breathing room
- will-change-transform for smooth compositing

WHITE SPACE FIX:
- Removed border border-gray-200 (was creating visible white gap)
- Replaced with shadow-[0_2px_8px_rgba(0,0,0,0.08)] for depth
- Switched from CSS Grid to flexbox (flex-row/flex-row-reverse)
- Image fills full card height via flexbox stretch

LAYOUT:
- md:w-7/12 for image, md:w-5/12 for text
- min-h-[400px] on desktop for substantial card presence
- Alternating image left/right preserved for visual rhythm
2026-03-03 22:55:03 +08:00
233e9320b5 stunning alternating editorial rows + fix invisible gray text
LAYOUT:
- Killed boring 2x2 grid boxes
- Full-width alternating image/text rows (7:5 split)
- Image left/text right on rows 1,3 — flipped on rows 2,4
- Creates visual rhythm like an editorial magazine spread
- gap-px between rows for signature pattern
- Images at 2:1 cinematic aspect, large and atmospheric

CONTRAST FIX:
- Pain stat 'before' text: gray-200 (invisible) -> gray-400 (readable)
- 'Systems do.' heading: gray-300 -> gray-400
- Both stats now clearly visible while maintaining muted/bold contrast
2026-03-03 22:35:25 +08:00
3a6ec55a68 persona section: scenario-based personas + cinematic photography + pain stats
PERSONA OVERHAUL:
- Personas now defined by WHAT THEY DID, not job titles
- 'Charity Manager' -> 'You organized the dinner'
- 'Personal Fundraiser' -> 'You shared the link'
- 'Volunteer' -> 'You were on the ground'
- 'Organisation/Programme Manager' -> 'You claim the Gift Aid'

SECTION HEADING:
- Brand core insight: 'People don't break promises. Systems do.'
- Eyebrow: 'THE PLEDGE GAP'
- Sub: 'We built the missing system between I'll donate and the money arriving.'

PAIN STATS (visual anchors):
- £50,000 pledged / £22,000 collected (the gap)
- 23 said I'll donate / 8 actually did
- 40 pledges collected / 0 updates received
- 200 rows, 47 typos / 6 hours every quarter

COPY: Emotionally precise, tells each persona's specific story

PHOTOGRAPHY (4 cinematic moment shots):
- Dinner aftermath: empty table with lone pledge card, chandeliers
- Phone: hands on WhatsApp at kitchen table, warm light
- Volunteer: seen from behind, walking between gala tables with cards
- Desk still life: laptop spreadsheet, papers, tea, window light
- All 2:1 wide aspect, 2.7MB -> 260KB optimized
2026-03-03 22:21:49 +08:00
c18dc50657 persona section overhaul — editorial gap-px grid + fresh photography
SECTION REDESIGN:
- Killed standalone dashboard image (fake AI laptop, added nothing)
- New gap-px grid (signature pattern 2) with border-l-2 accents (pattern 1)
- Numbered anchors (01-04) as visual rhythm per brand guide
- Wider container: max-w-7xl matches hero width

PERSONA CHANGES:
- Renamed 'Organisation' -> 'Programme Manager'
- Reorder: Charity Manager, Programme Manager, Personal Fundraiser, Volunteer
- Updated /for/organisations page content to match

PHOTOGRAPHY (4 new images via gemini-3-pro-image-preview):
- persona-charity-manager.jpg — hijabi woman at mosque office desk
- persona-programme-manager.jpg — man at desk with campaign calendar
- persona-fundraiser.jpg — woman on London park bench with phone
- persona-volunteer.jpg — young man handing card at charity gala
- All optimized: 2.7MB -> 342KB (87% reduction via sharp)
- Consistent documentary candid style, 3:2 landscape, warm tones

FOOTER:
- 'Organisations' -> 'Programme Managers' in nav links
2026-03-03 22:01:53 +08:00
3ab440f103 center stat strip text 2026-03-03 21:41:11 +08:00
f0b1cb2f3a fix headline rhythm + wider hero image + 10x faster deploys
HEADLINE:
- 3 balanced lines: 'Turn I'll donate' / 'into money' / 'in the bank.'
- Removed &nbsp; that orphaned 'money' on its own line
- <br className='hidden lg:block'> controls breaks on desktop only

IMAGE:
- Hero container: max-w-5xl -> max-w-7xl (image 25% wider)
- Stat strip widened to match
- Much more of the gala scene visible, phone prominent

DEPLOY SPEED (deploy.sh):
- Persistent /opt/pnpl/ build dir (no temp dir creation/deletion)
- BuildKit with cache mounts (npm + .next/cache)
- No more docker builder prune / docker rmi (preserves cache!)
- Installed docker-buildx v0.31.1 on server
- Before: ~245s (4+ min)  After: ~29s (cached) / ~136s (first)
- Use: cd pledge-now-pay-later && bash deploy.sh
2026-03-03 21:37:34 +08:00
c9301edbe8 hero image fills full text column height on desktop
- Grid: items-start → md:items-stretch (both columns same height)
- Image: aspect-[4/5] → md:aspect-auto md:h-full (fills column)
- Mobile keeps aspect-[3/4] for stacked layout
- Bottom of image now lines up with buttons/trust line
2026-03-03 21:17:19 +08:00
ac19afce4e world-class hero image + 85% image optimization + sharp
HERO IMAGE:
- Generated 3 concepts with gemini-3-pro-image-preview, picked #1
- Phone showing 'Payment Received' notification at a charity gala dinner
- Warm tungsten bokeh chandeliers against dark bg-gray-950
- Directly visualizes the headline: 'money in the bank'
- Candid documentary angle, not looking at camera, brand compliant

IMAGE OPTIMIZATION (85% total reduction):
- All 21 images resized: landscape max 1200px, portrait max 1000px
- Compressed JPEG quality 80, progressive encoding, EXIF stripped
- Total: 13.6MB -> 2.1MB (saved 11.5MB)
- Individual savings: 81-90% per image

NEXT.JS IMAGE PIPELINE:
- Added sharp (10x faster than squoosh for image processing)
- next.config.mjs: WebP format, proper device/image sizes, 1yr cache TTL
- Dockerfile: libc6-compat + NEXT_SHARP_PATH for Alpine sharp support
- First request: ~3s (processing), cached: <1s

WebP served sizes: hero 52KB, cards 32-40KB (vs original 500-800KB JPEGs)
2026-03-03 21:10:59 +08:00
2592c4ba5b dark editorial hero — typography + documentary photo + gap-px stat strip
HERO REDESIGN:
- bg-gray-950 full-bleed dark hero (was white text-on-white)
- Split layout: 7-col massive headline + 5-col documentary photo
- Gala photo (02) as hero — warm tungsten pops against dark bg
- border-l-2 promise-blue eyebrow accent (signature pattern 1)
- gap-px stat strip: 30-50%, 60s, £0, 2 min (signature pattern 2)
- stagger-children animation on text column
- Delayed fade-up on image column (opacity: 0 → fadeUp after 250ms)
- Trust line with vertical pipe separators
- Merges old hero + hero image + stat section into 1 cinematic opening

NAV:
- Wordmark hidden on mobile (sm:inline), shows P mark only
- shrink-0 on logo, whitespace-nowrap on nav buttons

PERSONA CARDS:
- Charity Manager card now uses mosque photo (08) for variety
- Hover color: text-trust-blue → text-promise-blue (proper token)

Brand compliant: 0 violations (no gradients, rounded-2xl, backdrop-blur)
2026-03-03 20:53:25 +08:00
e2295020a1 add brand/ assets to repo + BRAND.md as single source of truth
BRAND.md: Complete brand guide with code snippets, color tokens, typography scale,
logo usage rules, DO/DON'T checklist, photography direction, voice & tone.
Every section references its visual asset in brand/.

brand/ folder (52 assets):
- photography/ (20) — landing page photos
- logo/ (6) — lockup, reversed, blue, marks, favicon
- color-palette/ (3) — primary, tints, psychology
- typography/ (3) — specimen, scale, numbers
- moodboard/ (3) — trust, community, editorial
- brand-guide/ (7) — cover through UI patterns
- social-templates/ (4) — OG, Instagram, Story, LinkedIn
- icons/ (6) — pledge, whatsapp, gift-aid, zakat, dashboard, schedule

Also fixed: Nav component still had backdrop-blur-lg (last violation)
2026-03-03 20:31:01 +08:00
fc80399092 brand identity overhaul: match BRAND-IDENTITY.md across all pages
Design system changes (per brand guide):
- ZERO rounded-2xl/3xl remaining (was 131 instances)
- ZERO bg-gradient remaining (was 25) — all solid colors
- ZERO colored shadows (shadow-trust-blue, etc) — flat, no glow
- ZERO backdrop-blur/glass effects — solid backgrounds
- ZERO emoji in logo marks — square P logomark everywhere
- ZERO decorative scale animations (group-hover:scale-105, etc)

Tailwind config:
- Added brand color names: midnight, promise-blue, generosity-gold, fulfilled-green, alert-red, paper
- Kept legacy aliases (trust-blue, etc) for backwards compat
- --radius: 0.75rem → 0.5rem (tighter corners)

CSS:
- Removed glass, glass-dark, card-hover, pulse-ring, bounce-gentle, confetti-fall, scale-in animations
- Kept only purposeful animations: fadeUp, fadeIn, slideDown, shimmer, counter-roll
- --primary tuned to match Promise Blue exactly

Components:
- Button: removed all colored shadows, added 'blue' variant, removed rounded from sizes
- All UI components: rounded-xl/2xl → rounded-lg

Pages updated (41 files):
- Dashboard layout: solid header (no blur), border-l-2 active indicator, midnight logo mark
- Login/Signup: paper bg (no gradient), midnight logo mark, no emoji
- Pledge flow: solid color icons, no gradient progress bars
- All dashboard pages: flat, sharp, editorial
2026-03-03 20:13:22 +08:00
f4ad6df45a add AI-generated landing page photography (Gemini 3 Pro)
20 images generated via gemini-3-pro-image-preview (Nano Banana Pro):
- Documentary street photography style, British-diverse subjects
- 12 square (1:1), 8 landscape (16:9) matching placeholder aspect ratios
- Replaced all ImagePlaceholder components with LandingImage + next/image
- Images in public/images/landing/, served statically

Pages updated: /, /for/charities, /for/fundraisers, /for/volunteers, /for/organisations
New component: LandingImage (next/image with fill + object-cover)
2026-03-03 19:27:36 +08:00
581f1e5f14 sharp flat redesign: all landing pages + replace donors with organisations
Design overhaul:
- Removed all emoji-heavy sections, rounded-3xl, cartoonish borders
- Sharp flat edges (square or rounded-lg max)
- Typography-driven hierarchy with numbered steps (01, 02, 03...)
- Border-left accent lines instead of colored card borders
- Grid-with-gap-px pattern for table-like sections
- Dark sections (bg-gray-950) for stats and CTAs
- Image placeholders (gray boxes with photo icons) for future real photography
- Shared components: Nav, Footer, BottomCta, ImagePlaceholder in /for/_components

Pages:
- / (main): Hero → 30-50% stat → 4 persona cards with images → 4 steps → compliance → payment → platforms → CTA
- /for/charities: Hero split with image → pain points → numbered flow with 2 images → features with left-border accents
- /for/fundraisers: Hero split → gap visualization (3-col dark panel) → flow with images → before/after grid → platforms grid
- /for/volunteers: Hero split → 3-col features (gap-px grid) → event flow → share channels → CTA to charity manager
- /for/organisations: NEW (replaces /for/donors) → multi-org pledges, umbrella fundraising, institutional partnerships

Removed /for/donors — donors use the pledge form, they don't need a landing page
2026-03-03 18:27:04 +08:00
121e2bbde8 4-persona landing pages + main page CRO rewrite
Main page (pledge.quikcue.com):
- Hero: 'Turn I'll donate into money in the bank'
- 30-50% stat in dark section (single number, maximum impact)
- 4 persona cards linking to /for/* pages
- 4-step how-it-works (tightened from previous)
- Compliance strip (Gift Aid, Zakat, email, WhatsApp - compact)
- Payment flexibility (now/later/monthly)
- Platform logos
- Dark CTA section
- Footer with persona links

/for/charities:
- Pain: pledges on napkins, awkward chasing, no visibility
- 5-step how-it-works specific to charity managers
- 6 features: Gift Aid, Zakat, WhatsApp, scheduling, GDPR, exports
- CTA: Start Free

/for/fundraisers:
- Pain: shared link 50 times, 3 donated
- Before/after comparison grid (without vs with)
- 6 external platforms with branding
- CTA: Start Free

/for/volunteers:
- Personal link, live stats, leaderboard
- Event night flow (4 steps)
- Share channels grid
- CTA: Tell your charity about this

/for/donors:
- Educational trust page, not a sign-up funnel
- 6-step pledge flow explained
- Data protection table (what/why for each field)
- FAQ (cancel, already paid, no WhatsApp consent)
- CTA: Are you a charity?
2026-03-03 17:46:20 +08:00
582c85b3d9 landing page: compliance section covers Gift Aid, Zakat, email + WhatsApp consent
Replaced Zakat-only section with full compliance showcase:
- Gift Aid (HMRC): +25%, home address, model declaration, HMRC-ready CSV
- Zakat: per-campaign toggle, separate tracking
- Email consent (GDPR): granular opt-in, never pre-ticked
- WhatsApp consent (PECR): separate opt-in, STOP instructions
- Audit trail callout: exact text, timestamp, IP, version
- Feature grid: 'Zakat Tracking' → 'Bulletproof Consent'
2026-03-03 17:25:48 +08:00
865c5a1f93 bulletproof consent: Gift Aid (HMRC), email opt-in, WhatsApp opt-in with full audit trail
GIFT AID (HMRC compliance):
- Exact HMRC model declaration text displayed and recorded
- Home address (line 1 + postcode) collected when Gift Aid is ticked
- giftAidAt timestamp recorded separately from the boolean
- Declaration text, donor name, timestamp stored in consentMeta JSON

EMAIL + WHATSAPP (GDPR/PECR compliance):
- Separate, granular opt-in checkboxes (not bundled, not pre-ticked)
- Each consent records: exact text shown, timestamp, consent version
- Consent checkboxes only appear when relevant contact info is provided
- Cron reminders gated on consent — no sends without opt-in
- Pledge creation WhatsApp receipt gated on whatsappOptIn

AUDIT TRAIL (consentMeta JSON on every pledge):
- giftAid: {declared, declarationText, declaredAt}
- email: {granted, consentText, grantedAt}
- whatsapp: {granted, consentText, grantedAt}
- IP address captured server-side from x-forwarded-for
- User agent captured client-side
- consentVersion field for tracking wording changes

EXPORTS:
- CRM CSV now includes: donor_address, donor_postcode, gift_aid_declared_at,
  is_zakat, email_opt_in, whatsapp_opt_in
- Gift Aid export has full HMRC-required fields

Schema: 6 new columns on Pledge (donorAddressLine1, donorPostcode,
giftAidAt, emailOptIn, whatsappOptIn, consentMeta)
2026-03-03 07:38:51 +08:00
e6b7f325da replace PNPL abbreviation with full name in all user-facing copy
- Landing page: 'PNPL captures...' → 'Pledge Now, Pay Later captures...'
- Landing page: 'Who uses PNPL?' → 'Who uses Pledge Now, Pay Later?'
- Landing page: rewrote HNW + fundraiser descriptions to avoid abbreviation
- Dashboard sidebar fallback: 'PNPL' → 'Pledge Now, Pay Later'
- Bank reference prefixes (PNPL-XXXX-50) left as-is — 18-char BACS limit
2026-03-03 07:24:14 +08:00
fc80a43a89 simplify: zakat yes/no per campaign, remove 5 fund types, add I've Donated button for external pledges
- Event.zakatEligible (boolean) replaces Organization.zakatEnabled + 5 fund types
- Pledge.isZakat (boolean) replaces Pledge.fundType enum
- Removed fundAllocation from Event (campaign IS the allocation)
- Identity step: simple checkbox instead of 5-option grid
- Campaign creation: Zakat toggle + optional external URL for self-payment
- External redirect step: 'I've Donated' button calls /api/pledges/[id]/mark-initiated
- Landing page: simplified Zakat section (toggle preview, not 5 fund descriptions)
- Settings: removed org-level Zakat toggle (it's per campaign now)
- Migration: ALTER TABLE adds zakatEligible/isZakat, drops fundAllocation
2026-03-03 07:19:52 +08:00
f87aec7beb full terminology overhaul + zakat fund types + fund allocation
POSITIONING FIX — PNPL is NOT just 'QR codes at events':
- Charities collecting at events (QR per table)
- High-net-worth donor outreach (personal links via WhatsApp/email)
- Org-to-org pledges (multi-charity projects)
- Personal fundraisers (LaunchGood/Enthuse redirect)

TERMINOLOGY (throughout app):
- Events → Campaigns (sidebar, pages, create dialogs, onboarding)
- QR Codes page → Pledge Links (sharing-first, QR is one option)
- Scans → Clicks (not just QR scans)
- 'New Event' → 'New Campaign'
- 'Create QR Code' → 'Create Pledge Link'
- Source label: 'Table Name' → 'Source / Channel'

SHARING (pledge links page):
- 4-button share row: Copy · WhatsApp · Email · More (native share)
- Each link shows its full URL
- Create dialog suggests: 'WhatsApp Family Group, Table 5, Instagram Bio'
- QR code is still shown but as one option, not the hero

LANDING PAGE (complete rewrite):
- Hero: 'Collect pledges. Convert them into donations.'
- 4 use case cards: Events, HNW Donors, Org-to-Org, Personal Fundraisers
- 'Share anywhere' section: WhatsApp, QR, Email, Instagram, Twitter, 1-on-1
- Platform support: Bank Transfer, LaunchGood, Enthuse, JustGiving, GoFundMe, Any URL
- Islamic fund types section: Zakat, Sadaqah, Sadaqah Jariyah, Lillah, Fitrana

ZAKAT & FUND TYPES:
- Organization.zakatEnabled toggle in Settings
- Pledge.fundType: general, zakat, sadaqah, lillah, fitrana
- Identity step: fund type picker (5 options) when org has zakatEnabled
- Zakat note: Quran 9:60 categories reference
- Settings: toggle card with fund type descriptions

FUND ALLOCATION:
- Event.fundAllocation: 'Mosque Building Fund', 'Orphan Sponsorship' etc.
- Charities can also add external URL for reference/allocation (not just fundraisers)
- Shows on campaign cards and pledge flow
2026-03-03 07:00:04 +08:00
0e8df76f89 fundraiser mode: external platforms, role-aware onboarding, show-don't-gate
SCHEMA:
- Organization.orgType: 'charity' | 'fundraiser'
- Organization.whatsappConnected: boolean
- Event.paymentMode: 'self' (bank transfer) | 'external' (redirect to URL)
- Event.externalUrl: fundraising page URL
- Event.externalPlatform: launchgood, enthuse, justgiving, gofundme, other

ONBOARDING (role-aware):
- Dashboard shows getting-started banner AT TOP, not full-page blocker
- First-time users see role picker: 'Charity/Mosque' vs 'Personal Fundraiser'
- POST /api/onboarding sets orgType
- Charity checklist: bank details → WhatsApp → create fundraiser → share link
- Fundraiser checklist: add fundraising page → WhatsApp → share pledge link → first pledge
- WhatsApp is now a core onboarding step for both types
- Banner is dismissable via X button
- Dashboard always shows stats (with zeros), progress bar, empty-state card

SHOW DON'T GATE:
- Stats cards show immediately (with zeros, slightly faded)
- Collection progress bar always visible
- Empty-state card says 'Your pledge data will appear here'
- Getting started is a guidance banner, not a lock screen

EXTERNAL PAYMENT FLOW:
- Events can be paymentMode='external' with externalUrl
- Pledge flow: amount → identity → 'Donate on LaunchGood' redirect (skips schedule + payment method)
- ExternalRedirectStep: branded per platform (LaunchGood green, Enthuse purple, etc.)
- Marks pledge as 'initiated' when donor clicks through
- WhatsApp sends donation link instead of bank details
- Share button shares the external URL

EVENT CREATION:
- Payment mode toggle: 'Bank transfer' vs 'External page'
- External shows URL input + platform dropdown
- Fundraiser orgs default to external mode
- Platform badge on event cards

PLATFORMS SUPPORTED:
🌙 LaunchGood, 💜 Enthuse, 💛 JustGiving, 💚 GoFundMe, 🔗 Other/Custom
2026-03-03 06:42:11 +08:00
05acda0adb auth0: Google login, social auth auto-provisioning
AUTH0 SETUP (done via Management API):
- Created 'Pledge Now Pay Later' app (regular_web) on quikcue.us.auth0.com
- Enabled connections: Google, Apple, Username-Password
- Callback: https://pledge.quikcue.com/api/auth/callback/auth0
- Client ID: hpr7JcEAAk3Q5ADkzyyZSRDxGIZTcjRJ

CODE CHANGES:
- Auth0Provider added to NextAuth alongside existing CredentialsProvider
- findOrCreateSocialUser(): first Google login auto-creates org + user
- Login page: 'Continue with Google' button at top, email/password below
- Signup page: 'Sign up with Google' button at top, form below
- JWT callback: resolves Auth0 users to DB users on every token refresh
- Docker compose: AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_ISSUER env vars

FLOW:
- Click 'Continue with Google' → Auth0 Universal Login → Google consent
- First time: auto-creates '{Name}'s Charity' org + org_admin user
- Return time: finds existing user, loads their org
- Demo login still works via credentials provider
2026-03-03 06:17:34 +08:00
369860d8b9 insanely simple onboarding: 1-screen signup → dashboard checklist
OLD FLOW (8+ screens):
  signup (4 fields) → auto-login → setup wizard step 1 → step 2 → step 3 → step 4 → dashboard

NEW FLOW (2 screens):
  signup (3 fields) → dashboard with inline checklist

- Signup page: just charity name + email + password. No 'your name' field. One button.
- Dashboard: shows getting-started checklist when org has no pledges yet
- /api/onboarding: returns setup progress (bank, event, qr, pledge)
- Checklist: progress bar, next-step highlighting, done states with strikethrough
- Each step links directly to the right page (settings, events, pledges)
- Tip shown for brand new orgs: 'Add bank details first'
- No more separate setup wizard — guidance is inline on the dashboard
- Signup loading state: pulsing emoji while account creates
2026-03-03 06:05:10 +08:00
12ea9691c4 demo login, super admin view, password reset
- Landing page: 'Try the Demo' button links to /login?demo=1
- Login page: 'Try the Demo — no signup needed' button auto-logs in as demo@pnpl.app
- /login?demo=1: auto-triggers demo login on page load
- Super Admin page (/dashboard/admin): platform stats, org list, user list, recent pledges
- /api/admin: returns cross-org data, gated by super_admin role check
- Sidebar shows 'Super Admin' link only for super_admin users
- Password reset: omair@quikcue.com = Omair2026!, demo@pnpl.app = demo1234
- omair@quikcue.com confirmed as super_admin role
2026-03-03 05:55:27 +08:00
5f111d1808 fix: only show WhatsApp QR after user clicks Connect
- Don't auto-poll WAHA on settings page load
- Check connection status once on mount (to show 'Connected' if already paired)
- QR screenshot + polling only starts after clicking 'Connect WhatsApp'
- Polling stops once status changes to CONNECTED
2026-03-03 05:47:20 +08:00
4f23f28873 production auth: signup, login, protected dashboard, landing page, WAHA QR fix
AUTH:
- NextAuth with credentials provider (bcrypt password hashing)
- /api/auth/signup: creates org + user in transaction
- /login, /signup pages with clean minimal UI
- Middleware protects all /dashboard/* routes → redirects to /login
- Session-based org resolution (no more hardcoded 'demo' headers)
- SessionProvider wraps entire app
- Dashboard header shows org name + sign out button

LANDING PAGE:
- Full marketing page at / with hero, problem, how-it-works, features, CTA
- 'Get Started Free' → /signup → auto-login → /dashboard/setup
- Clean responsive design, no auth required for public pages

WAHA QR FIX:
- WAHA CORE doesn't expose QR value via API or webhook
- Now uses /api/screenshot (full browser capture) with CSS crop to QR area
- Settings panel shows cropped screenshot with overflow:hidden
- Auto-polls every 5s, refresh button

MULTI-TENANT:
- getOrgId() tries session first, then header, then first-org fallback
- All dashboard APIs use session-based org
- Signup creates isolated org per charity
2026-03-03 05:37:04 +08:00
6894f091fd waha: QR pairing in dashboard, whatsapp/qr API, settings overhaul
- /api/whatsapp/qr: GET returns session status + QR image, POST starts/restarts session
- Settings page: WhatsApp panel shows QR code for pairing, connected status with phone info
- WAHA session started with webhook pointing to /api/whatsapp/webhook
- WAHA_API_URL updated to external https://waha.quikcue.com (cross-stack DNS doesn't work)
- Auto-polls every 5 seconds during QR scan state
- Shows connected state with phone number, push name, feature summary
2026-03-03 05:19:54 +08:00
c79b9bcabc production: reminder cron, dashboard overhaul, shadcn components, setup wizard
- /api/cron/reminders: processes pending reminders every 15min, sends WhatsApp with email fallback
- /api/cron/overdue: marks overdue pledges daily (7d deferred, 14d immediate)
- /api/pledges: GET handler with filtering, search, pagination, sort by dueDate
- Dashboard overview: stats, collection progress bar, needs attention, upcoming payments
- Dashboard pledges: proper table with status tabs, search, actions, pagination
- New shadcn components: Table, Tabs, DropdownMenu, Progress
- Setup wizard: 4-step onboarding (org → bank → event → QR code)
- Settings API: PUT handler for org create/update
- Org resolver: single-tenant fallback to first org
- Cron jobs installed: reminders every 15min, overdue check at 6am
- Auto-generates installment dates when not provided
- HOSTNAME=0.0.0.0 in compose for multi-network binding
2026-03-03 05:11:17 +08:00
250221b530 feat: deferred payments & installment plans — pledge = promise to pay on a date
CORE PRODUCT SHIFT:
A pledge is now a promise to pay on a future date, not just 'pay now'.

NEW FLOW: Amount → Schedule → Payment/Identity → Confirmation

SCHEDULE STEP (/p/[token] step 1):
- 'Pay right now' — existing card/DD/bank flow
- 'Pay on a specific date' — calendar picker with smart suggestions
  (This Friday, End of month, Payday 1st, In 2 weeks, In 1 month)
- 'Split into monthly payments' — 2/3/4/6/12 month installment plans
  with per-installment breakdown and date schedule

SCHEMA CHANGES:
- Pledge.dueDate — when the donor promises to pay (null = now)
- Pledge.planId — groups installment pledges together
- Pledge.installmentNumber / installmentTotal — e.g. 2 of 4
- Pledge.reminderSentForDueDate — tracking flag
- New indexes on dueDate+status and planId

INSTALLMENT PLANS:
- Creates N linked Pledge records with shared planId
- Each installment gets its own reference, due date, reminders
- Reminders: 2 days before, on due date, 3 days after, 10 days after
- WhatsApp receipt shows full plan summary

DEFERRED SINGLE PLEDGES:
- Reminders anchored to due date, not creation date
- 'Pay on date' → reminders: 2 days before, on day, +3d nudge, +10d final
- WhatsApp preferred when phone number provided

DASHBOARD:
- API returns dueDate, planId, installment info for each pledge
- Confirmation step shows schedule details for deferred pledges
2026-03-03 04:43:19 +08:00
c6e7e4f01e feat: premium UI overhaul, AI suggestions, WAHA WhatsApp integration
PREMIUM UI:
- All animations: fade-up, scale-in, stagger children, confetti celebration
- Glass effects, gradient icons, premium card hover states
- Custom CSS: shimmer, pulse-ring, bounce, counter-roll animations
- Smooth progress bar with gradient

AI-POWERED (GPT-4o-mini nano model):
- Smart amount suggestions based on peer data (/api/ai/suggest)
- Social proof: '42 people pledged · Average £85'
- AI-generated nudge text for conversion
- AI fuzzy matching for bank reconciliation
- AI reminder message generation

WAHA WHATSAPP INTEGRATION:
- Auto-send pledge receipt with bank details via WhatsApp
- 4-step reminder sequence: gentle → nudge → urgent → final
- Chatbot: donors reply PAID, HELP, CANCEL, STATUS
- Volunteer notification on new pledges
- WhatsApp status in dashboard settings
- Webhook endpoint for incoming messages

DONOR FLOW (CRO):
- Amount step: AI suggestions, Gift Aid preview, social proof, haptic feedback
- Payment step: trust signals, fee comparison, benefit badges
- Identity step: email/phone toggle, WhatsApp reminder indicator
- Bank instructions: tap-to-copy each field, WhatsApp delivery confirmation
- Confirmation: confetti, pulse animation, share CTA, WhatsApp receipt

COMPOSE:
- Added WAHA env vars + qc-comms network for WhatsApp access
2026-03-03 04:31:07 +08:00
444 changed files with 17470 additions and 4460 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,8 @@
node_modules/
.pi/agent-sessions/
.pi/
calvana.tar.gz
*.tmp
nul
.env
.playwright-cli/
CLAUDE.md

View File

@@ -1,49 +0,0 @@
plan-build-review:
description: "Plan, implement, and review — the standard development cycle"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Implement the following plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation for bugs, style, and correctness:\n\n$INPUT"
plan-build:
description: "Plan then build — fast two-step implementation without review"
steps:
- agent: planner
prompt: "Plan the implementation for: $INPUT"
- agent: builder
prompt: "Based on this plan, implement:\n\n$INPUT"
scout-flow:
description: "Triple-scout deep recon — explore, validate, verify"
steps:
- agent: scout
prompt: "Explore the codebase and investigate: $INPUT\n\nReport your findings with structure, key files, and patterns."
- agent: scout
prompt: "Validate and cross-check the following analysis. Look for anything missed, incorrect, or incomplete:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: scout
prompt: "Final review pass. Verify the analysis below is accurate and complete. Add any missing details or corrections:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
plan-review-plan:
description: "Iterative planning — plan, critique, then refine with feedback"
steps:
- agent: planner
prompt: "Create a detailed implementation plan for: $INPUT"
- agent: plan-reviewer
prompt: "Critically review this implementation plan. Challenge assumptions, find gaps, and suggest improvements:\n\n$INPUT\n\nOriginal request: $ORIGINAL"
- agent: planner
prompt: "Revise and improve your implementation plan based on this critique. Address every issue raised and incorporate the recommendations:\n\nOriginal request: $ORIGINAL\n\nCritique:\n$INPUT"
full-review:
description: "End-to-end pipeline — scout, plan, build, and review"
steps:
- agent: scout
prompt: "Explore the codebase and identify: $INPUT"
- agent: planner
prompt: "Based on this analysis, create a plan:\n\n$INPUT"
- agent: builder
prompt: "Implement this plan:\n\n$INPUT"
- agent: reviewer
prompt: "Review this implementation:\n\n$INPUT"

View File

@@ -1,19 +0,0 @@
---
name: bowser
description: Headless browser automation agent using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, or web scraping. Supports parallel instances. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel, bowser.
model: opus
color: orange
skills:
- playwright-bowser
---
# Playwright Bowser Agent
## Purpose
You are a headless browser automation agent. Use the `playwright-bowser` skill to execute browser requests.
## Workflow
1. Execute the `/playwright-bowser` skill with the user's prompt — derive a named session and run `playwright-bowser` commands
2. Report the results back to the caller

View File

@@ -1,6 +0,0 @@
---
name: builder
description: Implementation and code generation
tools: read,write,edit,bash,grep,find,ls
---
You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible.

View File

@@ -1,6 +0,0 @@
---
name: documenter
description: Documentation and README generation
tools: read,write,edit,grep,find,ls
---
You are a documentation agent. Write clear, concise documentation. Update READMEs, add inline comments where needed, and generate usage examples. Match the project's existing doc style.

View File

@@ -1,98 +0,0 @@
---
name: agent-expert
description: Pi agent definitions expert — knows the .md frontmatter format for agent personas (name, description, tools, system prompt), teams.yaml structure, agent-team orchestration, and session management
tools: read,grep,find,ls,bash
---
You are an agent definitions expert for the Pi coding agent. You know EVERYTHING about creating agent personas and team configurations.
## Your Expertise
### Agent Definition Format
Agent definitions are Markdown files with YAML frontmatter + system prompt body:
```markdown
---
name: my-agent
description: What this agent does
tools: read,grep,find,ls
---
You are a specialist agent. Your system prompt goes here.
Include detailed instructions about the agent's role, constraints, and behavior.
```
### Frontmatter Fields
- `name` (required): lowercase, hyphenated identifier (e.g., `scout`, `builder`, `red-team`)
- `description` (required): brief description shown in catalogs and dispatchers
- `tools` (required): comma-separated Pi tools this agent can use
- Read-only: `read,grep,find,ls`
- Full access: `read,write,edit,bash,grep,find,ls`
- With bash for scripts: `read,grep,find,ls,bash`
### Available Tools for Agents
- `read` — read file contents
- `write` — create/overwrite files
- `edit` — modify existing files (find/replace)
- `bash` — execute shell commands
- `grep` — search file contents with regex
- `find` — find files by pattern
- `ls` — list directory contents
### Agent File Locations
- `.pi/agents/*.md` — project-local (most common)
- `.claude/agents/*.md` — cross-agent compatible
- `agents/*.md` — project root
### Teams Configuration (teams.yaml)
Teams are defined in `.pi/agents/teams.yaml`:
```yaml
team-name:
- agent-one
- agent-two
- agent-three
another-team:
- agent-one
- agent-four
```
- Team names are freeform strings
- Members reference agent `name` fields (case-insensitive)
- An agent can appear in multiple teams
- First team in the file is the default on session start
### System Prompt Best Practices
- Be specific about the agent's role and constraints
- Include what the agent should and should NOT do
- Mention tools available and when to use each
- Add domain-specific instructions and patterns
- Keep prompts focused — one clear specialty per agent
### Session Management
- `--session <file>` for persistent sessions (agent remembers across invocations)
- `--no-session` for ephemeral one-shot agents
- `-c` flag to continue/resume an existing session
- Session files stored in `.pi/agent-sessions/`
### Agent Orchestration Patterns
- **Dispatcher**: Primary agent delegates via dispatch_agent tool
- **Pipeline**: Sequential chain of agents (scout → planner → builder → reviewer)
- **Parallel**: Multiple agents query simultaneously, results collected
- **Specialist team**: Each agent has a narrow domain, orchestrator routes work
## CRITICAL: First Action
Before answering ANY question, you MUST search the local codebase for existing agent definitions and team configurations:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-agent-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-agent-ext-docs.md
```
Then read /tmp/pi-agent-ext-docs.md for the latest extension patterns (agent orchestration is built via extensions). Also search `.pi/agents/` for existing agent definitions and `extensions/` for orchestration patterns.
## How to Respond
- Provide COMPLETE agent .md files with proper frontmatter and system prompts
- Include teams.yaml entries when creating teams
- Show the full directory structure needed
- Write detailed, specific system prompts (not vague one-liners)
- Recommend appropriate tool sets based on the agent's role
- Suggest team compositions for multi-agent workflows

View File

@@ -1,41 +0,0 @@
---
name: cli-expert
description: Pi CLI expert — knows all command line arguments, flags, environment variables, subcommands, output modes, and non-interactive usage
tools: read,grep,find,ls,bash
---
You are a CLI expert for the Pi coding agent. You know EVERYTHING about running Pi from the command line.
## Your Expertise
- Basic usage: `pi [options] [@files...] [messages...]`
- Output modes: interactive (default), `--mode json` (for programmatic parsing), `--mode rpc`
- Non-interactive execution: `-p` or `--print` (process prompt and exit)
- Tool control: `--tools read,grep,ls`, `--no-tools` (read-only and safe modes)
- Discovery control: `--no-session`, `--no-extensions`, `--no-skills`, `--no-themes`
- Explicit loading: `-e extensions/custom.ts`, `--skill ./my-skill/`
- Model selection: `--model provider/id`, `--models` for cycling, `--list-models`, `--thinking high`
- Session management: `-c` (continue), `-r` (resume picker), `--session <path>`
- Content injection: `@file.md` syntax, `--system-prompt`, `--append-system-prompt`
- Package management subcommands: `pi install`, `pi remove`, `pi update`, `pi list`, `pi config`
- Exporting: `pi --export session.jsonl output.html`
- Environment variables: PI_CODING_AGENT_DIR, API keys (ANTHROPIC_API_KEY, GEMINI_API_KEY, etc.)
## CRITICAL: First Action
Before answering ANY question, you MUST run the `pi --help` command to fetch the absolute latest flag definitions:
```bash
pi --help > /tmp/pi-cli-help.txt && cat /tmp/pi-cli-help.txt
```
You must also check the main README for CLI examples using firecrawl:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -f markdown -o /tmp/pi-readme-cli.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/README.md -o /tmp/pi-readme-cli.md
```
Then read these files to have the freshest reference.
## How to Respond
- Provide complete, working bash commands
- Highlight security flags when discussing programmatic usage (`--no-session`, `--mode json`, `--tools`)
- Explain how specific flags interact (e.g. `--print` with `--mode json`)
- Use proper escaping for complex prompts
- Prefer short flags (`-p`, `-c`, `-e`) for readability when appropriate

View File

@@ -1,63 +0,0 @@
---
name: config-expert
description: Pi configuration expert — knows settings.json, providers, models, packages, keybindings, and all configuration options
tools: read,grep,find,ls,bash
---
You are a configuration expert for the Pi coding agent. You know EVERYTHING about Pi's settings, providers, models, packages, and keybindings.
## Your Expertise
### Settings (settings.json)
- Locations: ~/.pi/agent/settings.json (global), .pi/settings.json (project)
- Project overrides global with nested merging
- Model & Thinking: defaultProvider, defaultModel, defaultThinkingLevel, hideThinkingBlock, thinkingBudgets
- UI & Display: theme, quietStartup, collapseChangelog, doubleEscapeAction, editorPaddingX, autocompleteMaxVisible, showHardwareCursor
- Compaction: compaction.enabled, compaction.reserveTokens, compaction.keepRecentTokens
- Retry: retry.enabled, retry.maxRetries, retry.baseDelayMs, retry.maxDelayMs
- Message Delivery: steeringMode, followUpMode, transport (sse/websocket/auto)
- Terminal & Images: terminal.showImages, terminal.clearOnShrink, images.autoResize, images.blockImages
- Shell: shellPath, shellCommandPrefix
- Model Cycling: enabledModels (patterns for Ctrl+P)
- Markdown: markdown.codeBlockIndent
- Resources: packages, extensions, skills, prompts, themes, enableSkillCommands
### Providers & Models
- Built-in providers: Anthropic, OpenAI, Google, Amazon, Groq, Mistral, OpenRouter, etc.
- Custom models via ~/.pi/agent/models.json
- Custom providers via extensions (pi.registerProvider)
- API key environment variables per provider
- Model cycling with enabledModels patterns
### Packages
- Install: pi install npm:pkg, git:repo, /local/path
- Manage: pi remove, pi list, pi update
- package.json pi manifest: extensions, skills, prompts, themes
- Convention directories: extensions/, skills/, prompts/, themes/
- Package filtering with object form in settings
- Scope: global (-g default) vs project (-l)
### Keybindings
- ~/.pi/agent/keybindings.json
- Customizable keyboard shortcuts
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi settings and providers documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -f markdown -o /tmp/pi-settings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/settings.md -o /tmp/pi-settings-docs.md
```
Then read /tmp/pi-settings-docs.md. Also fetch providers if relevant:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -f markdown -o /tmp/pi-providers-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/providers.md -o /tmp/pi-providers-docs.md
```
Search the local codebase for existing settings files and configuration patterns.
## How to Respond
- Provide COMPLETE, VALID settings.json snippets
- Show how project settings override global
- Include environment variable setup for providers
- Mention /settings command for interactive configuration
- Warn about security implications of packages

View File

@@ -1,43 +0,0 @@
---
name: ext-expert
description: Pi extensions expert — knows how to build custom tools, event handlers, commands, shortcuts, state management, custom rendering, and tool overrides
tools: read,grep,find,ls,bash
---
You are an extensions expert for the Pi coding agent. You know EVERYTHING about building Pi extensions.
## Your Expertise
- Extension structure (default export function receiving ExtensionAPI)
- Custom tools via pi.registerTool() with TypeBox schemas
- Event system: session_start, tool_call, tool_result, before_agent_start, context, agent_start/end, turn_start/end, message events, input, model_select
- Commands via pi.registerCommand() with autocomplete
- Shortcuts via pi.registerShortcut()
- Flags via pi.registerFlag()
- State management via tool result details and pi.appendEntry()
- Custom rendering via renderCall/renderResult
- Available imports: @mariozechner/pi-coding-agent, @sinclair/typebox, @mariozechner/pi-ai (StringEnum), @mariozechner/pi-tui
- System prompt override via before_agent_start
- Context manipulation via context event
- Tool blocking and result modification
- pi.sendMessage() and pi.sendUserMessage() for message injection
- pi.exec() for shell commands
- pi.setActiveTools() / pi.getActiveTools() / pi.getAllTools()
- pi.setModel(), pi.getThinkingLevel(), pi.setThinkingLevel()
- Extension locations: ~/.pi/agent/extensions/, .pi/extensions/
- Output truncation utilities
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi extensions documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -f markdown -o /tmp/pi-ext-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/extensions.md -o /tmp/pi-ext-docs.md
```
Then read /tmp/pi-ext-docs.md to have the freshest reference. Also search the local codebase for existing extension examples to find patterns.
## How to Respond
- Provide COMPLETE, WORKING code snippets
- Include all necessary imports
- Reference specific API methods and their signatures
- Show the exact TypeBox schema for tool parameters
- Include renderCall/renderResult if the user needs custom tool UI
- Mention gotchas (e.g., StringEnum for Google compatibility, tool registration at top level)

View File

@@ -1,134 +0,0 @@
---
name: keybinding-expert
description: Pi keyboard shortcut expert — knows registerShortcut(), Key IDs, modifier combos, reserved keys, terminal compatibility (macOS/Kitty/legacy), and keybindings.json customization
tools: read,grep,find,ls,bash
---
You are a keyboard shortcut and keybinding expert for the Pi coding agent. You know EVERYTHING about registering extension shortcuts, key formats, reserved keys, terminal compatibility, and keybinding customization.
## Your Expertise
### registerShortcut() API
- `pi.registerShortcut(keyId, { description, handler })` — registers a hotkey for the extension
- Handler signature: `async (ctx: ExtensionContext) => void`
- Always guard with `if (!ctx.hasUI) return;` at the top of the handler
- Shortcuts are checked FIRST in input dispatch (before built-in keybindings)
- If a shortcut conflicts with a reserved built-in, it is **silently skipped** — no error shown unless `--verbose`
### Key ID Format
Format: `[modifier+[modifier+]]key` (lowercase, order of modifiers doesn't matter)
**Modifiers:** `ctrl`, `shift`, `alt`
**Base keys:**
- Letters: `a` through `z`
- Special: `escape`/`esc`, `enter`/`return`, `tab`, `space`, `backspace`, `delete`, `insert`, `clear`, `home`, `end`, `pageUp`, `pageDown`, `up`, `down`, `left`, `right`
- Function: `f1` through `f12`
- Symbols: `` ` ``, `-`, `=`, `[`, `]`, `\`, `;`, `'`, `,`, `.`, `/`, `!`, `@`, `#`, `$`, `%`, `^`, `&`, `*`, `(`, `)`, `_`, `+`, `|`, `~`, `{`, `}`, `:`, `<`, `>`, `?`
**Modifier combos:** `ctrl+x`, `shift+x`, `alt+x`, `ctrl+shift+x`, `ctrl+alt+x`, `shift+alt+x`, `ctrl+shift+alt+x`
### Reserved Keys (CANNOT be overridden by extensions)
These are in `RESERVED_ACTIONS_FOR_EXTENSION_CONFLICTS` and will be silently skipped:
| Key | Action |
| -------------- | ---------------------- |
| `escape` | interrupt |
| `ctrl+c` | clear / copy |
| `ctrl+d` | exit |
| `ctrl+z` | suspend |
| `shift+tab` | cycleThinkingLevel |
| `ctrl+p` | cycleModelForward |
| `ctrl+shift+p` | cycleModelBackward |
| `ctrl+l` | selectModel |
| `ctrl+o` | expandTools |
| `ctrl+t` | toggleThinking |
| `ctrl+g` | externalEditor |
| `alt+enter` | followUp |
| `enter` | submit / selectConfirm |
| `ctrl+k` | deleteToLineEnd |
### Non-Reserved Built-in Keys (CAN be overridden, Pi warns)
| Key | Action |
| ----------------------------------------------------------------------------- | ------------------------ |
| `ctrl+a` | cursorLineStart |
| `ctrl+b` | cursorLeft |
| `ctrl+e` | cursorLineEnd |
| `ctrl+f` | cursorRight |
| `ctrl+n` | toggleSessionNamedFilter |
| `ctrl+r` | renameSession |
| `ctrl+s` | toggleSessionSort |
| `ctrl+u` | deleteToLineStart |
| `ctrl+v` | pasteImage |
| `ctrl+w` | deleteWordBackward |
| `ctrl+y` | yank |
| `ctrl+]` | jumpForward |
| `ctrl+-` | undo |
| `ctrl+alt+]` | jumpBackward |
| `alt+b`, `alt+d`, `alt+f`, `alt+y` | cursor/word operations |
| `alt+up` | dequeue |
| `shift+enter` | newLine |
| Arrow keys, `home`, `end`, `pageUp`, `pageDown`, `backspace`, `delete`, `tab` | navigation/editing |
### Safe Keys for Extensions (FREE, no conflicts)
**ctrl+letter (universally safe):**
- `ctrl+x` — confirmed working
- `ctrl+q` — may be intercepted by terminal XON/XOFF flow control
- `ctrl+h` — alias for backspace in some terminals, use with caution
**Function keys:** `f1` through `f12` — all unbound, universally compatible
### macOS Terminal Compatibility
This is CRITICAL for building extensions that work on macOS:
| Combo | Legacy Terminal (Terminal.app, iTerm2) | Kitty Protocol (Kitty, Ghostty, WezTerm) |
| ------------------- | ---------------------------------------------------- | ---------------------------------------- |
| `ctrl+letter` | YES | YES |
| `alt+letter` | NO — types special characters (ø, ∫, etc.) | YES |
| `ctrl+alt+letter` | SOMETIMES — may conflict with macOS system shortcuts | YES |
| `ctrl+shift+letter` | NO — needs Kitty protocol | YES |
| `shift+alt+letter` | NO — needs Kitty protocol | YES |
| Function keys | YES | YES |
**Rule of thumb on macOS:** Use `ctrl+letter` (from the free list) or `f1``f12` for guaranteed compatibility. Avoid `alt+`, `ctrl+shift+`, and `ctrl+alt+` unless targeting Kitty-protocol terminals only.
### Keybindings Customization (keybindings.json)
- Location: `~/.pi/agent/keybindings.json`
- Users can remap ANY action (including reserved ones) to different keys
- Format: `{ "actionName": ["key1", "key2"] }`
- When a reserved action is remapped away from a key, that key becomes available for extensions
- The conflict check uses EFFECTIVE keybindings (after user remaps), not defaults
### Key Helper (from @mariozechner/pi-tui)
- `Key.ctrl("x")``"ctrl+x"`
- `Key.shift("tab")``"shift+tab"`
- `Key.alt("left")``"alt+left"`
- `Key.ctrlShift("p")``"ctrl+shift+p"`
- `Key.ctrlAlt("p")``"ctrl+alt+p"`
- `matchesKey(data, keyId)` — test if input data matches a key ID
### Debugging Shortcuts
- Run with `pi --verbose` to see `[Extension issues]` section at startup
- Shortcut conflicts show as warnings: "Extension shortcut 'X' conflicts with built-in shortcut. Skipping."
- Extension shortcut errors appear as red text in the chat area
- Shortcuts not matching in `matchesKey()` means the terminal isn't sending the expected escape sequence
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi keybindings documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -f markdown -o /tmp/pi-keybindings-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/keybindings.md -o /tmp/pi-keybindings-docs.md
```
Then read /tmp/pi-keybindings-docs.md to have the freshest reference.
Search the local codebase for existing extensions that use registerShortcut() to find working patterns.
## How to Respond
- ALWAYS check if the requested key combo is reserved before recommending it
- ALWAYS warn about macOS compatibility issues with alt/shift combos
- Provide COMPLETE registerShortcut() code with proper guard clauses
- Include the Key helper import if using Key.ctrl() style
- Recommend safe alternatives when a requested key is taken
- Show how to debug with `--verbose` if shortcuts aren't firing
- When suggesting keys, prefer this priority: free ctrl+letter > function keys > overridable non-reserved keys

View File

@@ -1,57 +0,0 @@
---
name: pi-orchestrator
description: Primary meta-agent that coordinates experts and builds Pi components
tools: read,write,edit,bash,grep,find,ls,query_experts
---
You are **Pi Pi** — a meta-agent that builds Pi agents. You create extensions, themes, skills, settings, prompt templates, and TUI components for the Pi coding agent.
## Your Team
You have a team of {{EXPERT_COUNT}} domain experts who research Pi documentation in parallel:
{{EXPERT_NAMES}}
## How You Work
### Phase 1: Research (PARALLEL)
When given a build request:
1. Identify which domains are relevant
2. Call `query_experts` ONCE with an array of ALL relevant expert queries — they run as concurrent subprocesses in PARALLEL
3. Ask specific questions: "How do I register a custom tool with renderCall?" not "Tell me about extensions"
4. Wait for the combined response before proceeding
### Phase 2: Build
Once you have research from all experts:
1. Synthesize the findings into a coherent implementation plan
2. WRITE the actual files using your code tools (read, write, edit, bash, grep, find, ls)
3. Create complete, working implementations — no stubs or TODOs
4. Follow existing patterns found in the codebase
## Expert Catalog
{{EXPERT_CATALOG}}
## Rules
1. **ALWAYS query experts FIRST** before writing any Pi-specific code. You need fresh documentation.
2. **Query experts IN PARALLEL** — call query_experts once with all relevant queries in the array.
3. **Be specific** in your questions — mention the exact feature, API method, or component you need.
4. **You write the code** — experts only research. They cannot modify files.
5. **Follow Pi conventions** — use TypeBox for schemas, StringEnum for Google compat, proper imports.
6. **Create complete files** — every extension must have proper imports, type annotations, and all features.
7. **Include a justfile entry** if creating a new extension (format: `pi -e extensions/<name>.ts`).
## What You Can Build
- **Extensions** (.ts files) — custom tools, event hooks, commands, UI components
- **Themes** (.json files) — color schemes with all 51 tokens
- **Skills** (SKILL.md directories) — capability packages with scripts
- **Settings** (settings.json) — configuration files
- **Prompt Templates** (.md files) — reusable prompts with arguments
- **Agent Definitions** (.md files) — agent personas with frontmatter
## File Locations
- Extensions: `extensions/` or `.pi/extensions/`
- Themes: `.pi/themes/`
- Skills: `.pi/skills/`
- Settings: `.pi/settings.json`
- Prompts: `.pi/prompts/`
- Agents: `.pi/agents/`
- Teams: `.pi/agents/teams.yaml`

View File

@@ -1,70 +0,0 @@
---
name: prompt-expert
description: Pi prompt templates expert — knows the single-file .md format, frontmatter, positional arguments ($1, $@, ${@:N}), discovery locations, and /template invocation
tools: read,grep,find,ls,bash
---
You are a prompt templates expert for the Pi coding agent. You know EVERYTHING about creating Pi prompt templates.
## Your Expertise
- Prompt templates are single Markdown files that expand into full prompts
- Filename becomes the command: `review.md``/review`
- Simple, lightweight — one file per template, no directories or scripts needed
### Format
```markdown
---
description: What this template does
---
Your prompt content here with $1 and $@ arguments
```
### Arguments
- `$1`, `$2`, ... — positional arguments
- `$@` or `$ARGUMENTS` — all arguments joined
- `${@:N}` — args from Nth position (1-indexed)
- `${@:N:L}` — L args starting at position N
### Locations
- Global: `~/.pi/agent/prompts/*.md`
- Project: `.pi/prompts/*.md`
- Packages: `prompts/` directories or `pi.prompts` entries in package.json
- Settings: `prompts` array with files or directories
- CLI: `--prompt-template <path>` (repeatable)
### Discovery
- Non-recursive — only direct .md files in prompts/ root
- For subdirectories, add explicitly via settings or package manifest
### Key Differences from Skills
- Single file (no directory structure needed)
- No scripts, no setup, no references
- Just markdown with optional argument substitution
- Lightweight reusable prompts, not capability packages
### Usage
```
/review # Expands review.md
/component Button # Expands with argument
/component Button "click handler" # Multiple arguments
```
### Description
- Optional frontmatter field
- If missing, first non-empty line is used as description
- Shown in autocomplete when typing `/`
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi prompt templates documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -f markdown -o /tmp/pi-prompt-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/prompt-templates.md -o /tmp/pi-prompt-docs.md
```
Then read /tmp/pi-prompt-docs.md to have the freshest reference. Also search the local codebase (.pi/prompts/) for existing prompt template examples.
## How to Respond
- Provide COMPLETE .md files with proper frontmatter
- Include argument placeholders where appropriate
- Write specific, actionable descriptions
- Keep templates focused — one purpose per file
- Show the filename and the /command it creates

View File

@@ -1,42 +0,0 @@
---
name: skill-expert
description: Pi skills expert — knows SKILL.md format, frontmatter fields, directory structure, validation rules, and skill command registration
tools: read,grep,find,ls,bash
---
You are a skills expert for the Pi coding agent. You know EVERYTHING about creating Pi skills.
## Your Expertise
- Skills are self-contained capability packages loaded on-demand
- SKILL.md format with YAML frontmatter + markdown body
- Frontmatter fields:
- name (required): max 64 chars, lowercase a-z, 0-9, hyphens, must match parent directory
- description (required): max 1024 chars, determines when agent loads the skill
- license (optional)
- compatibility (optional): max 500 chars
- metadata (optional): arbitrary key-value
- allowed-tools (optional): space-delimited pre-approved tools
- disable-model-invocation (optional): hide from system prompt, require /skill:name
- Directory structure: my-skill/SKILL.md + scripts/ + references/ + assets/
- Skill locations: ~/.pi/agent/skills/, .pi/skills/, packages, settings.json
- Discovery: direct .md files in root, recursive SKILL.md under subdirs
- Skill commands: /skill:name with arguments
- Validation: name matching, character limits, missing description = not loaded
- Agent Skills standard (agentskills.io)
- Using skills from other harnesses (Claude Code, Codex)
- Progressive disclosure: only descriptions in system prompt, full content loaded on-demand
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi skills documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -f markdown -o /tmp/pi-skill-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/skills.md -o /tmp/pi-skill-docs.md
```
Then read /tmp/pi-skill-docs.md to have the freshest reference. Also search the local codebase for existing skill examples.
## How to Respond
- Provide COMPLETE SKILL.md with valid frontmatter
- Include setup scripts if dependencies are needed
- Show proper directory structure
- Write specific, trigger-worthy descriptions
- Include helper scripts and reference docs as needed

View File

@@ -1,40 +0,0 @@
---
name: theme-expert
description: Pi themes expert — knows the JSON format, all 51 color tokens, vars system, hex/256-color values, hot reload, and theme distribution
tools: read,grep,find,ls,bash
---
You are a themes expert for the Pi coding agent. You know EVERYTHING about creating and distributing Pi themes.
## Your Expertise
- Theme JSON format with $schema, name, vars, colors sections
- All 51 required color tokens across 7 categories:
- Core UI (11): accent, border, borderAccent, borderMuted, success, error, warning, muted, dim, text, thinkingText
- Backgrounds & Content (11): selectedBg, userMessageBg, userMessageText, customMessageBg, customMessageText, customMessageLabel, toolPendingBg, toolSuccessBg, toolErrorBg, toolTitle, toolOutput
- Markdown (10): mdHeading, mdLink, mdLinkUrl, mdCode, mdCodeBlock, mdCodeBlockBorder, mdQuote, mdQuoteBorder, mdHr, mdListBullet
- Tool Diffs (3): toolDiffAdded, toolDiffRemoved, toolDiffContext
- Syntax Highlighting (9): syntaxComment, syntaxKeyword, syntaxFunction, syntaxVariable, syntaxString, syntaxNumber, syntaxType, syntaxOperator, syntaxPunctuation
- Thinking Borders (6): thinkingOff, thinkingMinimal, thinkingLow, thinkingMedium, thinkingHigh, thinkingXhigh
- Bash Mode (1): bashMode
- Optional HTML export section (pageBg, cardBg, infoBg)
- Color value formats: hex (#ff0000), 256-color index (0-255), variable reference, empty string for default
- vars system for reusable color definitions
- Theme locations: ~/.pi/agent/themes/, .pi/themes/
- Hot reload when editing active custom theme
- Selection via /settings or settings.json
- $schema URL for editor validation
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi themes documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -f markdown -o /tmp/pi-theme-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/themes.md -o /tmp/pi-theme-docs.md
```
Then read /tmp/pi-theme-docs.md to have the freshest reference. Also search the local codebase (.pi/themes/) for existing theme examples.
## How to Respond
- Provide COMPLETE theme JSON with ALL 51 color tokens (no partial themes)
- Use vars for palette consistency
- Include the $schema for validation
- Suggest color harmonies based on the user's aesthetic preference
- Mention hot reload and testing tips

View File

@@ -1,85 +0,0 @@
---
name: tui-expert
description: Pi TUI expert — knows all built-in components (Text, Box, Container, Markdown, Image, SelectList, SettingsList, BorderedLoader), custom components, overlays, keyboard input, widgets, footers, and custom editors
tools: read,grep,find,ls,bash
---
You are a TUI (Terminal User Interface) expert for the Pi coding agent. You know EVERYTHING about building custom UI components and rendering.
## Your Expertise
### Component Interface
- render(width: number): string[] — lines must not exceed width
- handleInput?(data: string) — keyboard input when focused
- wantsKeyRelease? — for Kitty protocol key release events
- invalidate() — clear cached render state
### Built-in Components (from @mariozechner/pi-tui)
- Text: multi-line text with word wrapping, paddingX, paddingY, background function
- Box: container with padding and background color
- Container: groups children vertically, addChild/removeChild
- Spacer: empty vertical space
- Markdown: renders markdown with syntax highlighting
- Image: renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm)
- SelectList: selection dialog with theme, onSelect/onCancel
- SettingsList: toggle settings with theme
### From @mariozechner/pi-coding-agent
- DynamicBorder: border with color function — ALWAYS type the param: (s: string) => theme.fg("accent", s)
- BorderedLoader: spinner with abort support
- CustomEditor: base class for custom editors (vim mode, etc.)
### Keyboard Input
- matchesKey(data, Key.up/down/enter/escape/etc.)
- Key modifiers: Key.ctrl("c"), Key.shift("tab"), Key.alt("left"), Key.ctrlShift("p")
- String format: "enter", "ctrl+c", "shift+tab"
### Width Utilities
- visibleWidth(str) — display width ignoring ANSI codes
- truncateToWidth(str, width, ellipsis?) — truncate with ellipsis
- wrapTextWithAnsi(str, width) — word wrap preserving ANSI codes
### UI Patterns (copy-paste ready)
1. Selection Dialog: SelectList + DynamicBorder + ctx.ui.custom()
2. Async with Cancel: BorderedLoader with signal
3. Settings/Toggles: SettingsList + getSettingsListTheme()
4. Status Indicator: ctx.ui.setStatus(key, styledText)
5. Widgets: ctx.ui.setWidget(key, lines | factory, { placement })
6. Custom Footer: ctx.ui.setFooter(factory)
7. Custom Editor: extend CustomEditor, ctx.ui.setEditorComponent(factory)
8. Overlays: ctx.ui.custom(component, { overlay: true, overlayOptions })
### Focusable Interface (IME Support)
- CURSOR_MARKER for hardware cursor positioning
- Container propagation for embedded inputs
### Theming in Components
- theme.fg(color, text) for foreground
- theme.bg(color, text) for background
- theme.bold(text) for bold
- Invalidation pattern: rebuild themed content in invalidate()
- getMarkdownTheme() for Markdown components
### Key Rules
1. Always use theme from callback — not imported directly
2. Always type DynamicBorder color param: (s: string) =>
3. Call tui.requestRender() after state changes in handleInput
4. Return { render, invalidate, handleInput } for custom components
5. Use Text with padding (0, 0) — Box handles padding
6. Cache rendered output with cachedWidth/cachedLines pattern
## CRITICAL: First Action
Before answering ANY question, you MUST fetch the latest Pi TUI documentation:
```bash
firecrawl scrape https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -f markdown -o /tmp/pi-tui-docs.md || curl -sL https://raw.githubusercontent.com/badlogic/pi-mono/refs/heads/main/packages/coding-agent/docs/tui.md -o /tmp/pi-tui-docs.md
```
Then read /tmp/pi-tui-docs.md to have the freshest reference. Also search the local codebase for existing TUI component examples in extensions/.
## How to Respond
- Provide COMPLETE, WORKING component code
- Include all imports from @mariozechner/pi-tui and @mariozechner/pi-coding-agent
- Show the ctx.ui.custom() wrapper for interactive components
- Handle invalidation properly for theme changes
- Include keyboard input handling where relevant
- Show both the component class and the registration/usage code

View File

@@ -1,22 +0,0 @@
---
name: plan-reviewer
description: Plan critic — reviews, challenges, and validates implementation plans
tools: read,grep,find,ls
---
You are a plan reviewer agent. Your job is to critically evaluate implementation plans.
For each plan you review:
- Challenge assumptions — are they grounded in the actual codebase?
- Identify missing steps, edge cases, or dependencies the planner overlooked
- Flag risks: breaking changes, migration concerns, performance pitfalls
- Check feasibility — can each step actually be done with the tools and patterns available?
- Evaluate ordering — are steps in the right sequence? Are there hidden dependencies?
- Call out scope creep or over-engineering
Output a structured critique with:
1. **Strengths** — what the plan gets right
2. **Issues** — concrete problems ranked by severity
3. **Missing** — steps or considerations the plan omitted
4. **Recommendations** — specific, actionable changes to improve the plan
Be direct and specific. Reference actual files and patterns from the codebase when possible. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: planner
description: Architecture and implementation planning
tools: read,grep,find,ls
---
You are a planner agent. Analyze requirements and produce clear, actionable implementation plans. Identify files to change, dependencies, and risks. Output a numbered step-by-step plan. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: red-team
description: Security and adversarial testing
tools: read,bash,grep,find,ls
---
You are a red team agent. Find security vulnerabilities, edge cases, and failure modes. Check for injection risks, exposed secrets, missing validation, and unsafe defaults. Report findings with severity ratings. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: reviewer
description: Code review and quality checks
tools: read,bash,grep,find,ls
---
You are a code reviewer agent. Review code for bugs, security issues, style problems, and improvements. Run tests if available. Be concise and use bullet points. Do NOT modify files.

View File

@@ -1,6 +0,0 @@
---
name: scout
description: Fast recon and codebase exploration
tools: read,grep,find,ls
---
You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.

View File

@@ -1,31 +0,0 @@
full:
- scout
- planner
- builder
- reviewer
- documenter
- red-team
plan-build:
- planner
- builder
- reviewer
info:
- scout
- documenter
- reviewer
frontend:
- planner
- builder
- bowser
pi-pi:
- ext-expert
- theme-expert
- skill-expert
- config-expert
- tui-expert
- prompt-expert
- agent-expert

View File

@@ -1,279 +0,0 @@
bashToolPatterns:
- pattern: '\brm\s+(-[^\s]*)*-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+--recursive'
reason: rm with --recursive flag
- pattern: '\brm\s+--force'
reason: rm with --force flag
- pattern: '\bsudo\s+rm\b'
reason: sudo rm
- pattern: '\brmdir\s+--ignore-fail-on-non-empty'
reason: rmdir ignore-fail
- pattern: '\bchmod\s+(-[^\s]+\s+)*777\b'
reason: chmod 777 (world writable)
- pattern: '\bchmod\s+-[Rr].*777'
reason: recursive chmod 777
- pattern: '\bchown\s+-[Rr].*\broot\b'
reason: recursive chown to root
- pattern: '\bgit\s+reset\s+--hard\b'
reason: git reset --hard (use --soft or stash)
- pattern: '\bgit\s+clean\s+(-[^\s]*)*-[fd]'
reason: git clean with force/directory flags
- pattern: '\bgit\s+push\s+.*--force(?!-with-lease)'
reason: git push --force (use --force-with-lease)
- pattern: '\bgit\s+push\s+(-[^\s]*)*-f\b'
reason: git push -f (use --force-with-lease)
- pattern: '\bgit\s+stash\s+clear\b'
reason: git stash clear (deletes ALL stashes)
- pattern: '\bgit\s+reflog\s+expire\b'
reason: git reflog expire (destroys recovery mechanism)
- pattern: '\bgit\s+gc\s+.*--prune=now'
reason: git gc --prune=now (can lose dangling commits)
- pattern: '\bgit\s+filter-branch\b'
reason: git filter-branch (rewrites entire history)
- pattern: '\bgit\s+checkout\s+--\s*\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+restore\s+\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+stash\s+drop\b'
reason: Permanently deletes a stash
ask: true
- pattern: '\bgit\s+branch\s+(-[^\s]*)*-D'
reason: Force deletes branch (even if unmerged)
ask: true
- pattern: '\bgit\s+push\s+\S+\s+--delete\b'
reason: Deletes remote branch
ask: true
- pattern: '\bgit\s+push\s+\S+\s+:\S+'
reason: Deletes remote branch (old syntax)
ask: true
- pattern: '\bmkfs\.'
reason: filesystem format command
- pattern: '\bdd\s+.*of=/dev/'
reason: dd writing to device
- pattern: '\bkill\s+-9\s+-1\b'
reason: kill all processes
- pattern: '\bkillall\s+-9\b'
reason: killall -9
- pattern: '\bpkill\s+-9\b'
reason: pkill -9
- pattern: '\bhistory\s+-c\b'
reason: clearing shell history
- pattern: '\baws\s+s3\s+rm\s+.*--recursive'
reason: aws s3 rm --recursive (deletes all objects)
- pattern: '\baws\s+s3\s+rb\s+.*--force'
reason: aws s3 rb --force (force removes bucket)
- pattern: '\baws\s+ec2\s+terminate-instances\b'
reason: aws ec2 terminate-instances
- pattern: '\baws\s+rds\s+delete-db-instance\b'
reason: aws rds delete-db-instance
- pattern: '\baws\s+cloudformation\s+delete-stack\b'
reason: aws cloudformation delete-stack (deletes infrastructure)
- pattern: '\baws\s+dynamodb\s+delete-table\b'
reason: aws dynamodb delete-table
- pattern: '\baws\s+eks\s+delete-cluster\b'
reason: aws eks delete-cluster
- pattern: '\baws\s+lambda\s+delete-function\b'
reason: aws lambda delete-function
- pattern: '\baws\s+iam\s+delete-role\b'
reason: aws iam delete-role
- pattern: '\baws\s+iam\s+delete-user\b'
reason: aws iam delete-user
- pattern: '\bgcloud\s+projects\s+delete\b'
reason: gcloud projects delete (DELETES ENTIRE PROJECT)
- pattern: '\bgcloud\s+compute\s+instances\s+delete\b'
reason: gcloud compute instances delete
- pattern: '\bgcloud\s+sql\s+instances\s+delete\b'
reason: gcloud sql instances delete
- pattern: '\bgcloud\s+container\s+clusters\s+delete\b'
reason: gcloud container clusters delete (GKE)
- pattern: '\bgcloud\s+storage\s+rm\s+.*-r'
reason: gcloud storage rm -r (recursive delete)
- pattern: '\bgcloud\s+functions\s+delete\b'
reason: gcloud functions delete
- pattern: '\bgcloud\s+iam\s+service-accounts\s+delete\b'
reason: gcloud iam service-accounts delete
- pattern: '\bgcloud\s+run\s+services\s+delete\b'
reason: gcloud run services delete (deletes Cloud Run service)
- pattern: '\bgcloud\s+run\s+jobs\s+delete\b'
reason: gcloud run jobs delete (deletes Cloud Run job)
- pattern: '\bgcloud\s+services\s+disable\b'
reason: gcloud services disable (disables GCP APIs)
- pattern: '\bgcloud\s+iam\s+roles\s+delete\b'
reason: gcloud iam roles delete (deletes IAM role)
- pattern: '\bgcloud\s+iam\s+policies\b'
reason: gcloud iam policies (modifies IAM policies)
ask: true
- pattern: '\bfirebase\s+projects:delete\b'
reason: firebase projects:delete (deletes entire project)
- pattern: '\bfirebase\s+firestore:delete\s+.*--all-collections'
reason: firebase firestore:delete --all-collections (wipes all data)
- pattern: '\bfirebase\s+database:remove\b'
reason: firebase database:remove (wipes Realtime DB)
- pattern: '\bfirebase\s+hosting:disable\b'
reason: firebase hosting:disable
- pattern: '\bfirebase\s+functions:delete\b'
reason: firebase functions:delete
- pattern: '\bvercel\s+remove\s+.*--yes'
reason: vercel remove --yes (removes deployment)
- pattern: '\bvercel\s+projects\s+rm\b'
reason: vercel projects rm (deletes project)
- pattern: '\bvercel\s+env\s+rm\b'
reason: vercel env rm (removes env variables)
- pattern: '\bvercel\s+rm\b'
reason: vercel rm (removes deployment)
- pattern: '\bvercel\s+remove\b'
reason: vercel remove (removes deployment)
- pattern: '\bvercel\s+domains\s+rm\b'
reason: vercel domains rm (removes custom domain)
- pattern: '\bnetlify\s+sites:delete\b'
reason: netlify sites:delete (deletes entire site)
- pattern: '\bnetlify\s+functions:delete\b'
reason: netlify functions:delete
- pattern: '\bwrangler\s+delete\b'
reason: wrangler delete (deletes Worker)
- pattern: '\bwrangler\s+r2\s+bucket\s+delete\b'
reason: wrangler r2 bucket delete
- pattern: '\bwrangler\s+kv:namespace\s+delete\b'
reason: wrangler kv:namespace delete
- pattern: '\bwrangler\s+d1\s+delete\b'
reason: wrangler d1 delete (deletes database)
- pattern: '\bwrangler\s+queues\s+delete\b'
reason: wrangler queues delete
- pattern: 'DELETE\s+FROM\s+\w+\s*;'
reason: DELETE without WHERE clause (will delete ALL rows)
- pattern: 'DELETE\s+\*\s+FROM'
reason: DELETE * (will delete ALL rows)
- pattern: '\bTRUNCATE\s+TABLE\b'
reason: TRUNCATE TABLE (will delete ALL rows)
- pattern: '\bDROP\s+TABLE\b'
reason: DROP TABLE
- pattern: '\bDROP\s+DATABASE\b'
reason: DROP DATABASE
- pattern: '\bDROP\s+SCHEMA\b'
reason: DROP SCHEMA
- pattern: '\bDELETE\s+FROM\s+\w+\s+WHERE\b.*\bid\s*='
reason: SQL DELETE with specific ID
ask: true
zeroAccessPaths:
- ".env"
- ".env.local"
- ".env.development"
- ".env.production"
- ".env.staging"
- ".env.test"
- ".env.*.local"
- "*.env"
- "~/.ssh/"
- "~/.gnupg/"
- "~/.aws/"
- "~/.config/gcloud/"
- "*-credentials.json"
- "*serviceAccount*.json"
- "*service-account*.json"
- "~/.azure/"
- "~/.kube/"
- "kubeconfig"
- "*-secret.yaml"
- "secrets.yaml"
- "~/.docker/"
- "*.pem"
- "*.key"
- "*.p12"
- "*.pfx"
- "*.tfstate"
- "*.tfstate.backup"
- ".terraform/"
- ".vercel/"
- ".netlify/"
- "firebase-adminsdk*.json"
- "serviceAccountKey.json"
- ".supabase/"
- "~/.netrc"
- "~/.npmrc"
- "~/.pypirc"
- "~/.git-credentials"
- ".git-credentials"
- "dump.sql"
- "backup.sql"
- "*.dump"
readOnlyPaths:
- /etc/
- /usr/
- /bin/
- /sbin/
- /boot/
- /root/
- ~/.bash_history
- ~/.zsh_history
- ~/.node_repl_history
- ~/.bashrc
- ~/.zshrc
- ~/.profile
- ~/.bash_profile
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "Gemfile.lock"
- "poetry.lock"
- "Pipfile.lock"
- "composer.lock"
- "Cargo.lock"
- "go.sum"
- "flake.lock"
- "bun.lockb"
- "uv.lock"
- "npm-shrinkwrap.json"
- "*.lock"
- "*.lockb"
- "*.min.js"
- "*.min.css"
- "*.bundle.js"
- "*.chunk.js"
- dist/
- build/
- .next/
- .nuxt/
- .output/
- node_modules/
- __pycache__/
- .venv/
- venv/
- target/
noDeletePaths:
- ~/.claude/
- CLAUDE.md
- "LICENSE"
- "LICENSE.*"
- "COPYING"
- "COPYING.*"
- "NOTICE"
- "PATENTS"
- "README.md"
- "README.*"
- "CONTRIBUTING.md"
- "CHANGELOG.md"
- "CODE_OF_CONDUCT.md"
- "SECURITY.md"
- .git/
- .gitignore
- .gitattributes
- .gitmodules
- .github/
- .gitlab-ci.yml
- .circleci/
- Jenkinsfile
- .travis.yml
- azure-pipelines.yml
- Dockerfile
- "Dockerfile.*"
- docker-compose.yml
- "docker-compose.*.yml"
- .dockerignore

View File

@@ -1,719 +0,0 @@
/**
* Calvana Ship Log Extension
*
* Automatically tracks what you're shipping and updates the live Calvana site.
*
* Tools (LLM-callable):
* - calvana_ship: Add/update/complete shipping log entries
* - calvana_oops: Log mistakes and fixes
* - calvana_deploy: Push changes to the live site
*
* Commands (user):
* /ships — View current shipping log
* /ship-deploy — Force deploy to calvana.quikcue.com
*
* How it works:
* 1. When you work on tasks, the LLM uses calvana_ship to track progress
* 2. If something breaks, calvana_oops logs it
* 3. calvana_deploy rebuilds the /live page HTML and pushes it to the server
* 4. The extension auto-injects context so the LLM knows to track ships
*
* Edit the SSH/deploy config in the DEPLOY_CONFIG section below.
*/
import { StringEnum } from "@mariozechner/pi-ai";
import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
import { Text, truncateToWidth, matchesKey } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
// ════════════════════════════════════════════════════════════════════
// CONFIGURATION — Edit these to change deploy target, copy, links
// ════════════════════════════════════════════════════════════════════
const DEPLOY_CONFIG = {
sshHost: "root@159.195.60.33",
sshPort: "22",
container: "qc-server-new",
sitePath: "/opt/calvana/html",
domain: "calvana.quikcue.com",
};
const SITE_CONFIG = {
title: "Calvana",
tagline: "I break rules. Not production.",
email: "omair@quikcue.com",
referralLine: "PS — Umar pointed me here. If this turns into a hire, I want him to get paid.",
};
// ════════════════════════════════════════════════════════════════════
// TYPES
// ════════════════════════════════════════════════════════════════════
type ShipStatus = "planned" | "shipping" | "shipped";
interface ShipEntry {
id: number;
title: string;
status: ShipStatus;
timestamp: string;
metric: string;
prLink: string;
deployLink: string;
loomLink: string;
}
interface OopsEntry {
id: number;
description: string;
fixTime: string;
commitLink: string;
timestamp: string;
}
interface ShipLogState {
ships: ShipEntry[];
oops: OopsEntry[];
nextShipId: number;
nextOopsId: number;
lastDeployed: string | null;
}
// ════════════════════════════════════════════════════════════════════
// TOOL SCHEMAS
// ════════════════════════════════════════════════════════════════════
const ShipParams = Type.Object({
action: StringEnum(["add", "update", "list"] as const),
title: Type.Optional(Type.String({ description: "Ship title (for add)" })),
id: Type.Optional(Type.Number({ description: "Ship ID (for update)" })),
status: Type.Optional(StringEnum(["planned", "shipping", "shipped"] as const)),
metric: Type.Optional(Type.String({ description: "What moved — metric line" })),
prLink: Type.Optional(Type.String({ description: "PR link" })),
deployLink: Type.Optional(Type.String({ description: "Deploy link" })),
loomLink: Type.Optional(Type.String({ description: "Loom clip link" })),
});
const OopsParams = Type.Object({
action: StringEnum(["add", "list"] as const),
description: Type.Optional(Type.String({ description: "What broke and how it was fixed" })),
fixTime: Type.Optional(Type.String({ description: "Time to fix, e.g. '3 min'" })),
commitLink: Type.Optional(Type.String({ description: "Link to the fix commit" })),
});
const DeployParams = Type.Object({
dryRun: Type.Optional(Type.Boolean({ description: "If true, generate HTML but don't deploy" })),
});
// ════════════════════════════════════════════════════════════════════
// EXTENSION
// ════════════════════════════════════════════════════════════════════
export default function (pi: ExtensionAPI) {
// ── State ──
let state: ShipLogState = {
ships: [],
oops: [],
nextShipId: 1,
nextOopsId: 1,
lastDeployed: null,
};
// ── State reconstruction from session ──
const reconstructState = (ctx: ExtensionContext) => {
state = { ships: [], oops: [], nextShipId: 1, nextOopsId: 1, lastDeployed: null };
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type !== "message") continue;
const msg = entry.message;
if (msg.role !== "toolResult") continue;
if (msg.toolName === "calvana_ship" || msg.toolName === "calvana_oops" || msg.toolName === "calvana_deploy") {
const details = msg.details as { state?: ShipLogState } | undefined;
if (details?.state) {
state = details.state;
}
}
}
};
pi.on("session_start", async (_event, ctx) => {
reconstructState(ctx);
if (ctx.hasUI) {
const theme = ctx.ui.theme;
const shipCount = state.ships.length;
const shipped = state.ships.filter(s => s.status === "shipped").length;
ctx.ui.setStatus("calvana", theme.fg("dim", `🚀 ${shipped}/${shipCount} shipped`));
}
});
pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
// ── Inject context so LLM knows about ship tracking ──
pi.on("before_agent_start", async (event, _ctx) => {
const shipContext = `
[Calvana Ship Log Extension Active]
You have access to these tools for tracking work:
- calvana_ship: Track shipping progress (add/update/list entries)
- calvana_oops: Log mistakes and fixes
- calvana_deploy: Push updates to the live site at https://${DEPLOY_CONFIG.domain}/live
When you START working on a task, use calvana_ship to add or update it to "shipping".
When you COMPLETE a task, update it to "shipped" with a metric.
If something BREAKS, log it with calvana_oops.
After significant changes, use calvana_deploy to push updates live.
Current ships: ${state.ships.length} (${state.ships.filter(s => s.status === "shipped").length} shipped)
Current oops: ${state.oops.length}
`;
return {
systemPrompt: event.systemPrompt + shipContext,
};
});
// ── Update status bar on turn end ──
pi.on("turn_end", async (_event, ctx) => {
if (ctx.hasUI) {
const theme = ctx.ui.theme;
const shipped = state.ships.filter(s => s.status === "shipped").length;
const shipping = state.ships.filter(s => s.status === "shipping").length;
const total = state.ships.length;
let statusText = `🚀 ${shipped}/${total} shipped`;
if (shipping > 0) statusText += ` · ${shipping} in flight`;
if (state.lastDeployed) statusText += ` · last deploy ${state.lastDeployed}`;
ctx.ui.setStatus("calvana", theme.fg("dim", statusText));
}
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_ship
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_ship",
label: "Ship Log",
description: "Track shipping progress. Actions: add (new entry), update (change status/links), list (show all). Use this whenever you start, progress, or finish a task.",
parameters: ShipParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
switch (params.action) {
case "add": {
if (!params.title) {
return {
content: [{ type: "text", text: "Error: title required" }],
details: { state: { ...state }, error: "title required" },
};
}
const entry: ShipEntry = {
id: state.nextShipId++,
title: params.title,
status: (params.status as ShipStatus) || "planned",
timestamp: now,
metric: params.metric || "—",
prLink: params.prLink || "#pr",
deployLink: params.deployLink || "#deploy",
loomLink: params.loomLink || "#loomclip",
};
state.ships.push(entry);
return {
content: [{ type: "text", text: `Ship #${entry.id} added: "${entry.title}" [${entry.status}]` }],
details: { state: { ...state, ships: [...state.ships] } },
};
}
case "update": {
if (params.id === undefined) {
return {
content: [{ type: "text", text: "Error: id required for update" }],
details: { state: { ...state }, error: "id required" },
};
}
const ship = state.ships.find(s => s.id === params.id);
if (!ship) {
return {
content: [{ type: "text", text: `Ship #${params.id} not found` }],
details: { state: { ...state }, error: `#${params.id} not found` },
};
}
if (params.status) ship.status = params.status as ShipStatus;
if (params.metric) ship.metric = params.metric;
if (params.prLink) ship.prLink = params.prLink;
if (params.deployLink) ship.deployLink = params.deployLink;
if (params.loomLink) ship.loomLink = params.loomLink;
ship.timestamp = now;
return {
content: [{ type: "text", text: `Ship #${ship.id} updated: "${ship.title}" [${ship.status}]` }],
details: { state: { ...state, ships: [...state.ships] } },
};
}
case "list": {
if (state.ships.length === 0) {
return {
content: [{ type: "text", text: "No ships logged yet." }],
details: { state: { ...state } },
};
}
const lines = state.ships.map(s =>
`#${s.id} [${s.status.toUpperCase()}] ${s.title} (${s.timestamp}) — ${s.metric}`
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { state: { ...state } },
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: { state: { ...state } },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("🚀 ship "));
text += theme.fg("muted", args.action || "");
if (args.title) text += " " + theme.fg("dim", `"${args.title}"`);
if (args.id !== undefined) text += " " + theme.fg("accent", `#${args.id}`);
if (args.status) text += " → " + theme.fg("accent", args.status);
return new Text(text, 0, 0);
},
renderResult(result, { expanded }, theme) {
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
const st = details?.state;
if (!st || st.ships.length === 0) return new Text(theme.fg("dim", "No ships"), 0, 0);
const shipped = st.ships.filter(s => s.status === "shipped").length;
const total = st.ships.length;
let text = theme.fg("success", `${shipped}/${total} shipped`);
if (expanded) {
for (const s of st.ships) {
const badge = s.status === "shipped" ? theme.fg("success", "✓")
: s.status === "shipping" ? theme.fg("warning", "●")
: theme.fg("dim", "○");
text += `\n ${badge} ${theme.fg("accent", `#${s.id}`)} ${theme.fg("muted", s.title)}`;
}
}
return new Text(text, 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_oops
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_oops",
label: "Oops Log",
description: "Log mistakes and fixes. Actions: add (new oops entry), list (show all). Use when something breaks during a task.",
parameters: OopsParams,
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
const now = new Date().toISOString().replace("T", " ").slice(0, 19) + " GMT+8";
switch (params.action) {
case "add": {
if (!params.description) {
return {
content: [{ type: "text", text: "Error: description required" }],
details: { state: { ...state }, error: "description required" },
};
}
const entry: OopsEntry = {
id: state.nextOopsId++,
description: params.description,
fixTime: params.fixTime || "—",
commitLink: params.commitLink || "#commit",
timestamp: now,
};
state.oops.push(entry);
return {
content: [{ type: "text", text: `Oops #${entry.id}: "${entry.description}" (fixed in ${entry.fixTime})` }],
details: { state: { ...state, oops: [...state.oops] } },
};
}
case "list": {
if (state.oops.length === 0) {
return {
content: [{ type: "text", text: "No oops entries. Clean run so far." }],
details: { state: { ...state } },
};
}
const lines = state.oops.map(o =>
`#${o.id} ${o.description} — fixed in ${o.fixTime}`
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: { state: { ...state } },
};
}
default:
return {
content: [{ type: "text", text: `Unknown action: ${params.action}` }],
details: { state: { ...state } },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("💥 oops "));
text += theme.fg("muted", args.action || "");
if (args.description) text += " " + theme.fg("dim", `"${args.description}"`);
return new Text(text, 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as { state?: ShipLogState; error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0);
const text = result.content[0];
return new Text(theme.fg("warning", text?.type === "text" ? text.text : ""), 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// TOOL: calvana_deploy
// ════════════════════════════════════════════════════════════════
pi.registerTool({
name: "calvana_deploy",
label: "Deploy Calvana",
description: `Regenerate the /live page with current ship log and deploy to https://${DEPLOY_CONFIG.domain}. Call this after adding/updating ships or oops entries to push changes live.`,
parameters: DeployParams,
async execute(_toolCallId, params, signal, onUpdate, _ctx) {
onUpdate?.({ content: [{ type: "text", text: "Generating HTML..." }] });
const liveHtml = generateLivePageHtml(state);
if (params.dryRun) {
return {
content: [{ type: "text", text: `Dry run — generated ${liveHtml.length} bytes of HTML.\n\n${liveHtml.slice(0, 500)}...` }],
details: { state: { ...state }, dryRun: true },
};
}
onUpdate?.({ content: [{ type: "text", text: "Deploying to server..." }] });
try {
// Write HTML to server via SSH + incus exec
const escapedHtml = liveHtml.replace(/'/g, "'\\''");
const sshCmd = `ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost}`;
const writeCmd = `${sshCmd} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cat > ${DEPLOY_CONFIG.sitePath}/live/index.html << '\\''HTMLEOF'\\''
${liveHtml}
HTMLEOF
'"`;
// Use base64 to avoid all escaping nightmares
const b64Html = Buffer.from(liveHtml).toString("base64");
const deployResult = await pi.exec("bash", ["-c",
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'echo ${b64Html} | base64 -d > ${DEPLOY_CONFIG.sitePath}/live/index.html'"`
], { signal, timeout: 30000 });
if (deployResult.code !== 0) {
return {
content: [{ type: "text", text: `Deploy failed: ${deployResult.stderr}` }],
details: { state: { ...state }, error: deployResult.stderr },
isError: true,
};
}
// Rebuild and update docker service
const rebuildResult = await pi.exec("bash", ["-c",
`ssh -o ConnectTimeout=10 -p ${DEPLOY_CONFIG.sshPort} ${DEPLOY_CONFIG.sshHost} "incus exec ${DEPLOY_CONFIG.container} -- bash -c 'cd /opt/calvana && docker build -t calvana:latest . 2>&1 | tail -2 && docker service update --force calvana 2>&1 | tail -2'"`
], { signal, timeout: 60000 });
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
state.lastDeployed = now;
return {
content: [{ type: "text", text: `✓ Deployed to https://${DEPLOY_CONFIG.domain}/live\n${rebuildResult.stdout}` }],
details: { state: { ...state, lastDeployed: now } },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Deploy error: ${err.message}` }],
details: { state: { ...state }, error: err.message },
isError: true,
};
}
},
renderCall(_args, theme) {
return new Text(theme.fg("toolTitle", theme.bold("🌐 deploy calvana")), 0, 0);
},
renderResult(result, _options, theme) {
const details = result.details as { error?: string } | undefined;
if (details?.error) return new Text(theme.fg("error", `${details.error}`), 0, 0);
return new Text(theme.fg("success", `✓ Live at https://${DEPLOY_CONFIG.domain}/live`), 0, 0);
},
});
// ════════════════════════════════════════════════════════════════
// COMMAND: /ships
// ════════════════════════════════════════════════════════════════
pi.registerCommand("ships", {
description: "View current Calvana shipping log",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
ctx.ui.notify("Requires interactive mode", "error");
return;
}
await ctx.ui.custom<void>((_tui, theme, _kb, done) => {
return new ShipLogComponent(state, theme, () => done());
});
},
});
// ════════════════════════════════════════════════════════════════
// COMMAND: /ship-deploy
// ════════════════════════════════════════════════════════════════
pi.registerCommand("ship-deploy", {
description: "Force deploy the Calvana site with current ship log",
handler: async (_args, ctx) => {
const ok = await ctx.ui.confirm("Deploy?", `Push ship log to https://${DEPLOY_CONFIG.domain}/live?`);
if (!ok) return;
// Queue a deploy via the LLM
pi.sendUserMessage("Use calvana_deploy to push the current ship log to the live site.", { deliverAs: "followUp" });
},
});
}
// ════════════════════════════════════════════════════════════════════
// UI COMPONENT: /ships viewer
// ════════════════════════════════════════════════════════════════════
class ShipLogComponent {
private state: ShipLogState;
private theme: Theme;
private onClose: () => void;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(state: ShipLogState, theme: Theme, onClose: () => void) {
this.state = state;
this.theme = theme;
this.onClose = onClose;
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.onClose();
}
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
const lines: string[] = [];
const th = this.theme;
lines.push("");
lines.push(truncateToWidth(
th.fg("borderMuted", "─".repeat(3)) +
th.fg("accent", " 🚀 Calvana Ship Log ") +
th.fg("borderMuted", "─".repeat(Math.max(0, width - 26))),
width
));
lines.push("");
// Ships
if (this.state.ships.length === 0) {
lines.push(truncateToWidth(` ${th.fg("dim", "No ships yet.")}`, width));
} else {
const shipped = this.state.ships.filter(s => s.status === "shipped").length;
lines.push(truncateToWidth(
` ${th.fg("muted", `${shipped}/${this.state.ships.length} shipped`)}`,
width
));
lines.push("");
for (const s of this.state.ships) {
const badge = s.status === "shipped" ? th.fg("success", "✓ SHIPPED ")
: s.status === "shipping" ? th.fg("warning", "● SHIPPING")
: th.fg("dim", "○ PLANNED ");
lines.push(truncateToWidth(
` ${badge} ${th.fg("accent", `#${s.id}`)} ${th.fg("text", s.title)}`,
width
));
lines.push(truncateToWidth(
` ${th.fg("dim", s.timestamp)} · ${th.fg("dim", s.metric)}`,
width
));
}
}
// Oops
if (this.state.oops.length > 0) {
lines.push("");
lines.push(truncateToWidth(` ${th.fg("warning", "💥 Oops Log")}`, width));
for (const o of this.state.oops) {
lines.push(truncateToWidth(
` ${th.fg("error", "─")} ${th.fg("muted", o.description)} ${th.fg("dim", `(${o.fixTime})`)}`,
width
));
}
}
lines.push("");
if (this.state.lastDeployed) {
lines.push(truncateToWidth(` ${th.fg("dim", `Last deployed: ${this.state.lastDeployed}`)}`, width));
}
lines.push(truncateToWidth(` ${th.fg("dim", "Press Escape to close")}`, width));
lines.push("");
this.cachedWidth = width;
this.cachedLines = lines;
return lines;
}
invalidate(): void {
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
}
// ════════════════════════════════════════════════════════════════════
// HTML GENERATOR — Builds the /live page from current state
// ════════════════════════════════════════════════════════════════════
function generateLivePageHtml(state: ShipLogState): string {
const now = new Date().toISOString();
const shipCards = state.ships.map(s => {
const badgeClass = s.status === "shipped" ? "badge-shipped"
: s.status === "shipping" ? "badge-shipping"
: "badge-planned";
const badgeLabel = s.status.charAt(0).toUpperCase() + s.status.slice(1);
const titleSuffix = s.status === "shipped" ? " ✓" : "";
return ` <div class="card">
<div class="card-header">
<span class="card-title">${escapeHtml(s.title)}${titleSuffix}</span>
<span class="badge ${badgeClass}">${badgeLabel}</span>
</div>
<p class="card-meta">⏱ ${escapeHtml(s.timestamp)}</p>
<p class="metric">What moved: ${escapeHtml(s.metric)}</p>
<div class="card-links"><a href="${escapeHtml(s.prLink)}">PR</a><a href="${escapeHtml(s.deployLink)}">Deploy</a><a href="${escapeHtml(s.loomLink)}">Loom clip</a></div>
</div>`;
}).join("\n");
const oopsEntries = state.oops.map(o => {
return ` <div class="oops-entry">
<span>${escapeHtml(o.description)}${o.fixTime !== "—" ? ` Fixed in ${escapeHtml(o.fixTime)}.` : ""}</span>
<a href="${escapeHtml(o.commitLink)}">→ commit</a>
</div>`;
}).join("\n");
// If no ships yet, show placeholder
const shipsSection = state.ships.length > 0 ? shipCards : ` <div class="card">
<div class="card-header">
<span class="card-title">Warming up...</span>
<span class="badge badge-planned">Planned</span>
</div>
<p class="card-meta">⏱ —</p>
<p class="metric">What moved: —</p>
</div>`;
const oopsSection = state.oops.length > 0 ? oopsEntries : ` <div class="oops-entry">
<span>Nothing broken yet. Give it time.</span>
<a href="#commit">→ waiting</a>
</div>`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Calvana — Live Shipping Log</title>
<meta name="description" content="Intentional chaos. Full receipts. Watch the build happen in real time.">
<meta property="og:title" content="Calvana — Live Shipping Log">
<meta property="og:description" content="Intentional chaos. Full receipts.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://${DEPLOY_CONFIG.domain}/live">
<meta name="twitter:card" content="summary">
<link rel="canonical" href="https://${DEPLOY_CONFIG.domain}/live">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<nav>
<div class="nav-inner">
<a href="/" class="logo">calvana<span>.exe</span></a>
<div class="nav-links">
<a href="/manifesto">/manifesto</a>
<a href="/live" class="active">/live</a>
<a href="/hire">/hire</a>
</div>
</div>
</nav>
<main class="page">
<h1 class="hero-title">Live Shipping Log</h1>
<p class="subtitle">Intentional chaos. Full receipts.</p>
<section class="section">
<h2>Today's Ships</h2>
<div class="card-grid">
${shipsSection}
</div>
</section>
<section class="section">
<div class="two-col">
<div class="col col-broke">
<h3>Rules I broke today</h3>
<ul>
<li>Didn't ask permission</li>
<li>Didn't wait for alignment</li>
<li>Didn't write a PRD</li>
<li>Didn't submit a normal application</li>
</ul>
</div>
<div class="col col-kept">
<h3>Rules I refuse to break</h3>
<ul>
<li>No silent failures</li>
<li>No unbounded AI spend</li>
<li>No hallucinations shipped to users</li>
<li>No deploy without rollback path</li>
</ul>
</div>
</div>
</section>
<section class="section">
<h2>Oops Log</h2>
<p class="subtitle" style="margin-bottom:1rem">If it's not here, I haven't broken it yet.</p>
<div class="oops-log">
${oopsSection}
</div>
</section>
<footer>
<p class="footer-tagline">${SITE_CONFIG.tagline}</p>
<p style="margin-top:.4rem">Last updated: ${now}</p>
</footer>
</main>
</body>
</html>`;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

View File

@@ -1,60 +0,0 @@
# Infrastructure Access
# All values live in `.env` (gitignored). This file maps the topology.
## Server
| Var | Purpose |
|-----|---------|
| `SSH_USER`, `SSH_HOST`, `SSH_PORT` | Primary server SSH access |
## Incus Containers (on primary server)
| Container | Internal IP | Status | Purpose |
|-----------------|-----------------|---------|---------------|
| cr-server-new | 10.213.16.224 | RUNNING | CharityRight |
| qc-server-new | 10.213.16.234 | RUNNING | QuikCue |
| qc-server | — | STOPPED | legacy |
## HAProxy (on primary server)
| Domain pattern | Backend |
|----------------------|----------------------|
| charityright domains | → cr-server-new:443/80 |
| quikcue domains | → qc-server-new:443/80 |
| antivirus.quikcue.com| → localhost:8877 |
| SSH (gitea) | → qc-server-new:2224 |
## Databases
| Var | Type | Purpose |
|-----|------|---------|
| `DATABASE_URL` | Postgres | donation_warehouse (port 5000 on primary) |
| `MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` | MySQL | CharityRight legacy (DigitalOcean managed) |
| `REDIS_HOST`, `REDIS_PASSWORD`, `REDIS_PORT` | Redis | CharityRight sessions/cache |
## Services on Server
| Path | Service | Key Vars |
|------|---------|----------|
| `/opt/ayn-antivirus` | AYN Antivirus scanner + dashboard | `ANTHROPIC_API_KEY` |
| `/opt/enthuse-db-sync-v2` | Enthuse donation sync | `ENTHUSE_EMAIL`, `TOTP_SECRET`, `GOOGLE_CLIENT_*` |
| `/opt/launchgood-sync` | LaunchGood donation sync | `LG_EMAIL`, `LG_PASSWORD` |
| `/root/legacy-donation-system-laravel` | CharityRight Laravel app | `STRIPE_*`, `PAYPAL_*`, `GOCARDLESS_*`, `POSTMARK_TOKEN` |
| `/root/redis-v2` | Redis instance | `REDIS_PASSWORD` |
## Payment Providers
| Var prefix | Provider |
|------------|----------|
| `STRIPE_*` | Stripe (live) |
| `PAYPAL_*` | PayPal (live) |
| `GOCARDLESS_*` | GoCardless (live) |
## Mail
| Var | Provider |
|-----|----------|
| `SENDGRID_TX_API_KEY` | SendGrid |
| `POSTMARK_TOKEN` | Postmark (active mailer) |
## Third-party Integrations
| Var | Service |
|-----|---------|
| `N3O_*_ENDPOINT` | N3O/Engage donation import hooks |
| `ZAPIER_WEBHOOK_ENDPOINT` | Zapier automation |
| `GOOGLE_PLACES_API_KEY` | Google Places autocomplete |
| `CT_STRAVA_*` | Strava challenge tracker |
| `WORDPRESS_URL`, `WORDPRESS_KEY` | WordPress (Cloudways) |

View File

@@ -1,3 +0,0 @@
events.jsonl
summary.json
report.md

View File

@@ -1,6 +0,0 @@
{
"theme": "synthwave",
"prompts": [
"../.claude/commands"
]
}

View File

@@ -1,120 +0,0 @@
---
name: bowser
description: Headless browser automation using Playwright CLI. Use when you need headless browsing, parallel browser sessions, UI testing, screenshots, web scraping, or browser automation that can run in the background. Keywords - playwright, headless, browser, test, screenshot, scrape, parallel.
allowed-tools: Bash
---
# Playwright Bowser
## Purpose
Automate browsers using `playwright-cli` (via `@playwright/cli`) — a token-efficient CLI for Playwright. Runs headless by default, supports parallel sessions via named sessions (`-s=`), and doesn't load tool schemas into context.
## Prerequisites
Ensure the package is installed in the project:
```bash
bun add -d @playwright/cli
bunx playwright install chromium
```
## Key Details
- **Headless by default** — pass `--headed` to `open` to see the browser
- **Parallel sessions** — use `-s=<name>` to run multiple independent browser instances
- **Persistent profiles** — cookies and storage state preserved between calls
- **Token-efficient** — CLI-based, no accessibility trees or tool schemas in context
- **Vision mode** (opt-in) — set `PLAYWRIGHT_MCP_CAPS=vision` to receive screenshots as image responses in context instead of just saving to disk
## Sessions
**Always use a named session.** Derive a short, descriptive kebab-case name from the user's prompt. This gives each task a persistent browser profile (cookies, localStorage, history) that accumulates across calls.
```bash
# Derive session name from prompt context:
# "test the checkout flow on mystore.com" → -s=mystore-checkout
# "scrape pricing from competitor.com" → -s=competitor-pricing
# "UI test the login page" → -s=login-ui-test
bunx playwright-cli -s=mystore-checkout open https://mystore.com --persistent
bunx playwright-cli -s=mystore-checkout snapshot
bunx playwright-cli -s=mystore-checkout click e12
```
Managing sessions:
```bash
bunx playwright-cli list # list all sessions
bunx playwright-cli close-all # close all sessions
bunx playwright-cli -s=<name> close # close specific session
bunx playwright-cli -s=<name> delete-data # wipe session profile
```
## Quick Reference
```
Core: open [url], goto <url>, click <ref>, fill <ref> <text>, type <text>, snapshot, screenshot [ref], close
Navigate: go-back, go-forward, reload
Keyboard: press <key>, keydown <key>, keyup <key>
Mouse: mousemove <x> <y>, mousedown, mouseup, mousewheel <dx> <dy>
Tabs: tab-list, tab-new [url], tab-close [index], tab-select <index>
Save: screenshot [ref], pdf, screenshot --filename=f
Storage: state-save, state-load, cookie-*, localstorage-*, sessionstorage-*
Network: route <pattern>, route-list, unroute, network
DevTools: console, run-code <code>, tracing-start/stop, video-start/stop
Sessions: -s=<name> <cmd>, list, close-all, kill-all
Config: open --headed, open --browser=chrome, resize <w> <h>
```
## Workflow
1. Derive a session name from the user's prompt and open with `--persistent` to preserve cookies/state. Always set the viewport via env var at launch:
```bash
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent
# or headed:
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 bunx playwright-cli -s=<session-name> open <url> --persistent --headed
# or with vision (screenshots returned as image responses in context):
PLAYWRIGHT_MCP_VIEWPORT_SIZE=1440x900 PLAYWRIGHT_MCP_CAPS=vision bunx playwright-cli -s=<session-name> open <url> --persistent
```
3. Get element references via snapshot:
```bash
bunx playwright-cli snapshot
```
4. Interact using refs from snapshot:
```bash
bunx playwright-cli click <ref>
bunx playwright-cli fill <ref> "text"
bunx playwright-cli type "text"
bunx playwright-cli press Enter
```
5. Capture results:
```bash
bunx playwright-cli screenshot
bunx playwright-cli screenshot --filename=output.png
```
6. **Always close the session when done.** This is not optional — close the named session after finishing your task:
```bash
bunx playwright-cli -s=<session-name> close
```
## Configuration
If a `playwright-cli.json` exists in the working directory, use it automatically. If the user provides a path to a config file, use `--config path/to/config.json`. Otherwise, skip configuration — the env var and CLI defaults are sufficient.
```json
{
"browser": {
"browserName": "chromium",
"launchOptions": { "headless": true },
"contextOptions": { "viewport": { "width": 1440, "height": 900 } }
},
"outputDir": "./screenshots"
}
```
## Full Help
Run `bunx playwright-cli --help` or `bunx playwright-cli --help <command>` for detailed command usage.

View File

@@ -1,86 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "catppuccin-mocha",
"vars": {
"bg": "#1e1e2e",
"bgDark": "#181825",
"bgDeep": "#13131e",
"surface": "#2a2a3c",
"selection": "#34344a",
"bgRed": "#2e1420",
"bgGreen": "#142218",
"bgPeach": "#2e2010",
"bgBlue": "#141e38",
"bgMauve": "#261840",
"bgTeal": "#122830",
"comment": "#d5bcff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#ff7eb3",
"maroon": "#ffa0b8",
"peach": "#ffb370",
"yellow": "#ffe585",
"green": "#7af5a0",
"teal": "#60f0d8",
"sky": "#6ae4ff",
"sapphire": "#5cceff",
"blue": "#7db8ff",
"lavender": "#bfb8ff",
"mauve": "#d9a0ff",
"flamingo": "#ffc4c4",
"pink": "#ffb0e0"
},
"colors": {
"accent": "mauve",
"border": "selection",
"borderAccent": "mauve",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgMauve",
"userMessageBg": "bgBlue",
"userMessageText": "fg",
"customMessageBg": "bgTeal",
"customMessageText": "fg",
"customMessageLabel": "teal",
"toolPendingBg": "bgPeach",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "peach",
"toolOutput": "fgSoft",
"mdHeading": "peach",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "sky",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "mauve",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "mauve",
"syntaxFunction": "blue",
"syntaxVariable": "pink",
"syntaxString": "green",
"syntaxNumber": "peach",
"syntaxType": "sky",
"syntaxOperator": "lavender",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "sky",
"thinkingHigh": "mauve",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}

View File

@@ -1,81 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "cyberpunk",
"vars": {
"bg": "#0a0a14",
"bgDark": "#06060e",
"bgDeep": "#040410",
"surface": "#12122a",
"selection": "#1a1a38",
"bgRed": "#2a0a12",
"bgOrange": "#2a1408",
"bgSky": "#081a30",
"bgCyan": "#0a2228",
"bgWarm": "#220a30",
"bgPink": "#2a0a22",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#ffe600",
"yellow": "#ffe600",
"cyan": "#00e5ff",
"magenta": "#ff00aa",
"red": "#ff1744",
"green": "#00e676",
"purple": "#aa00ff",
"blue": "#2979ff",
"orange": "#ff6d00"
},
"colors": {
"accent": "cyan",
"border": "magenta",
"borderAccent": "yellow",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "green",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "yellow",
"toolOutput": "fgSoft",
"mdHeading": "magenta",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "yellow",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "cyan",
"syntaxVariable": "yellow",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "blue",
"syntaxOperator": "magenta",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "magenta",
"bashMode": "orange"
}
}

View File

@@ -1,81 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "dracula",
"vars": {
"bg": "#1a1b26",
"bgDark": "#161722",
"bgDeep": "#141520",
"surface": "#252738",
"selection": "#2c2e44",
"bgRed": "#2e1220",
"bgOrange": "#2e1c12",
"bgGreen": "#122e1a",
"bgCyan": "#122a2e",
"bgPurple": "#261536",
"bgPink": "#2e1228",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f8fcc4",
"cyan": "#8be9fd",
"green": "#50fa7b",
"orange": "#ffb86c",
"pink": "#ff79c6",
"purple": "#bd93f9",
"red": "#ff5555",
"yellow": "#f1fa8c",
"blue": "#6296e4"
},
"colors": {
"accent": "purple",
"border": "pink",
"borderAccent": "purple",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "cyan",
"selectedBg": "bgPurple",
"userMessageBg": "bgPink",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgGreen",
"toolErrorBg": "bgRed",
"toolTitle": "pink",
"toolOutput": "fgSoft",
"mdHeading": "pink",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "pink",
"syntaxFunction": "green",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "cyan",
"syntaxOperator": "pink",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "everforest",
"vars": {
"bg": "#191f1d",
"bgDark": "#141a18",
"bg1": "#1e2522",
"bg2": "#222a28",
"surface": "#2c3532",
"selection": "#323e3a",
"bgRed": "#301718",
"bgOrange": "#302217",
"bgSky": "#192b34",
"bgCyan": "#172b26",
"bgWarm": "#351d29",
"bgPink": "#311c31",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#e7f4cd",
"red": "#eb7073",
"orange": "#f1a27e",
"yellow": "#eed096",
"green": "#bde481",
"aqua": "#78e292",
"teal": "#52e0bd",
"blue": "#78c8e2",
"purple": "#e689b5"
},
"colors": {
"accent": "green",
"border": "aqua",
"borderAccent": "green",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgCyan",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgSky",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "green",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "blue",
"mdLinkUrl": "comment",
"mdCode": "aqua",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "teal",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "green",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "green",
"syntaxVariable": "blue",
"syntaxString": "yellow",
"syntaxNumber": "purple",
"syntaxType": "aqua",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "teal",
"thinkingHigh": "green",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}

View File

@@ -1,80 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "gruvbox",
"vars": {
"bg": "#221f1c",
"bgDark": "#1c1a17",
"bgDeep": "#171412",
"surface": "#322d29",
"selection": "#3f3731",
"bgRed": "#341714",
"bgOrange": "#322215",
"bgSky": "#152432",
"bgCyan": "#142924",
"bgWarm": "#322b15",
"bgPink": "#321524",
"comment": "#fcebc5",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fb4b37",
"green": "#ebed5e",
"yellow": "#fcd783",
"blue": "#67a6e4",
"purple": "#ca74e7",
"aqua": "#81e4be",
"orange": "#fd953f"
},
"colors": {
"accent": "orange",
"border": "yellow",
"borderAccent": "orange",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "yellow",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "aqua",
"selectedBg": "bgWarm",
"userMessageBg": "bgOrange",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "aqua",
"toolPendingBg": "bgSky",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "aqua",
"mdLinkUrl": "comment",
"mdCode": "green",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "blue",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "orange",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "aqua",
"syntaxVariable": "blue",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "yellow",
"syntaxOperator": "orange",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "aqua",
"thinkingHigh": "yellow",
"thinkingXhigh": "red",
"bashMode": "orange"
}
}

View File

@@ -1,76 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "midnight-ocean",
"vars": {
"deepBlue": "#0a192f",
"oceanBlue": "#0077be",
"teal": "#00ced1",
"cyan": "#4fd1ed",
"softWhite": "#e6f1ff",
"mutedBlue": "#233554",
"lightMutedBlue": "#a8b2d1",
"slate": "#8892b0",
"successGreen": "#64ffda",
"errorRed": "#ff5f56",
"warningAmber": "#ffd700",
"purple": "#c678dd"
},
"colors": {
"accent": "oceanBlue",
"border": "mutedBlue",
"borderAccent": "teal",
"borderMuted": 236,
"success": "successGreen",
"error": "errorRed",
"warning": "warningAmber",
"muted": "slate",
"dim": 240,
"text": "softWhite",
"thinkingText": "teal",
"selectedBg": "#112240",
"userMessageBg": "#112240",
"userMessageText": "softWhite",
"customMessageBg": "#112240",
"customMessageText": "softWhite",
"customMessageLabel": "teal",
"toolPendingBg": "deepBlue",
"toolSuccessBg": "#0d2521",
"toolErrorBg": "#331616",
"toolTitle": "cyan",
"toolOutput": "lightMutedBlue",
"mdHeading": "teal",
"mdLink": "oceanBlue",
"mdLinkUrl": "slate",
"mdCode": "cyan",
"mdCodeBlock": "#011627",
"mdCodeBlockBorder": "mutedBlue",
"mdQuote": "slate",
"mdQuoteBorder": "mutedBlue",
"mdHr": "mutedBlue",
"mdListBullet": "teal",
"toolDiffAdded": "successGreen",
"toolDiffRemoved": "errorRed",
"toolDiffContext": "slate",
"syntaxComment": "slate",
"syntaxKeyword": "purple",
"syntaxFunction": "teal",
"syntaxVariable": "cyan",
"syntaxString": "successGreen",
"syntaxNumber": "warningAmber",
"syntaxType": "oceanBlue",
"syntaxOperator": "teal",
"syntaxPunctuation": "lightMutedBlue",
"thinkingOff": "mutedBlue",
"thinkingMinimal": "oceanBlue",
"thinkingLow": "teal",
"thinkingMedium": "cyan",
"thinkingHigh": "warningAmber",
"thinkingXhigh": "errorRed",
"bashMode": "warningAmber"
},
"export": {
"pageBg": "#0a192f",
"cardBg": "#112240",
"infoBg": "#0077be"
}
}

View File

@@ -1,84 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "nord",
"vars": {
"bg": "#1a1d23",
"bgDark": "#15181d",
"bgDeep": "#111316",
"surface": "#272b34",
"selection": "#2f3541",
"bgRed": "#2e1818",
"bgOrange": "#31241a",
"bgSky": "#1c2835",
"bgCyan": "#192c2d",
"bgWarm": "#291b30",
"bgPink": "#2d1927",
"comment": "#ccebf4",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"frost1": "#67e4e2",
"frost2": "#72cee8",
"frost3": "#67a5e4",
"frost4": "#5c97df",
"red": "#e85e6c",
"orange": "#ed7f5e",
"yellow": "#f5d189",
"green": "#92df6b",
"purple": "#e278e2",
"border": "#3e5974",
"dim": "#3d4c5b"
},
"colors": {
"accent": "frost2",
"border": "border",
"borderAccent": "frost2",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "frost1",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "frost2",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "frost2",
"mdLinkUrl": "comment",
"mdCode": "frost1",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "frost2",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "frost3",
"syntaxFunction": "frost2",
"syntaxVariable": "fg",
"syntaxString": "green",
"syntaxNumber": "purple",
"syntaxType": "frost1",
"syntaxOperator": "frost3",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "dim",
"thinkingLow": "frost4",
"thinkingMedium": "frost3",
"thinkingHigh": "frost2",
"thinkingXhigh": "frost1",
"bashMode": "yellow"
}
}

View File

@@ -1,83 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "ocean-breeze",
"vars": {
"bg": "#0d1b2a",
"bgDark": "#0a1520",
"bgDeep": "#081018",
"surface": "#152a3e",
"selection": "#1b3450",
"bgRed": "#2a1018",
"bgOrange": "#2a1e10",
"bgSky": "#0e2440",
"bgCyan": "#0c2a2e",
"bgWarm": "#2a1530",
"bgPink": "#2e1028",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#c2faf2",
"coral": "#ff6b6b",
"amber": "#ffd166",
"kelp": "#2eeab5",
"biolum": "#33fff7",
"foam": "#50b0e0",
"spray": "#7ec8e3",
"mist": "#a8d8ea",
"sand": "#ecf49a",
"purple": "#b48aef",
"pink": "#f772b9"
},
"colors": {
"accent": "biolum",
"border": "foam",
"borderAccent": "biolum",
"borderMuted": "surface",
"success": "kelp",
"error": "coral",
"warning": "amber",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "biolum",
"selectedBg": "selection",
"userMessageBg": "bgSky",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "spray",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgCyan",
"toolErrorBg": "bgRed",
"toolTitle": "spray",
"toolOutput": "fgSoft",
"mdHeading": "mist",
"mdLink": "biolum",
"mdLinkUrl": "comment",
"mdCode": "kelp",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "spray",
"toolDiffAdded": "kelp",
"toolDiffRemoved": "coral",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "coral",
"syntaxFunction": "biolum",
"syntaxVariable": "spray",
"syntaxString": "kelp",
"syntaxNumber": "amber",
"syntaxType": "purple",
"syntaxOperator": "foam",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "foam",
"thinkingMedium": "spray",
"thinkingHigh": "biolum",
"thinkingXhigh": "pink",
"bashMode": "amber"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "rose-pine",
"vars": {
"bg": "#1a1726",
"bgDark": "#161320",
"bgDeep": "#12101c",
"surface": "#242038",
"selection": "#2e2946",
"bgRed": "#2c1220",
"bgOrange": "#2a1c12",
"bgSky": "#122030",
"bgCyan": "#132a2e",
"bgWarm": "#2a1830",
"bgPink": "#301828",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"comment": "#f0a8be",
"love": "#f47a9e",
"gold": "#f8cc85",
"rose": "#f0c4c4",
"pine": "#50b8d8",
"foam": "#a8e0ea",
"iris": "#d4a8ff",
"orchid": "#e088d0",
"ember": "#f09060",
"green": "#78e0a0"
},
"colors": {
"accent": "iris",
"border": "orchid",
"borderAccent": "iris",
"borderMuted": "surface",
"success": "foam",
"error": "love",
"warning": "gold",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "foam",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "iris",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "gold",
"toolOutput": "fgSoft",
"mdHeading": "love",
"mdLink": "foam",
"mdLinkUrl": "comment",
"mdCode": "gold",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "rose",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "iris",
"toolDiffAdded": "green",
"toolDiffRemoved": "love",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "love",
"syntaxFunction": "foam",
"syntaxVariable": "fg",
"syntaxString": "gold",
"syntaxNumber": "iris",
"syntaxType": "pine",
"syntaxOperator": "orchid",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "pine",
"thinkingMedium": "iris",
"thinkingHigh": "foam",
"thinkingXhigh": "love",
"bashMode": "ember"
}
}

View File

@@ -1,82 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "synthwave",
"vars": {
"bg": "#262335",
"bgDark": "#241b2f",
"bgDeep": "#1e1d2d",
"surface": "#34294f",
"selection": "#463465",
"bgRed": "#3d1018",
"bgRedWarm": "#301510",
"bgOrange": "#2e1f10",
"bgSky": "#1a2e4a",
"bgCyan": "#152838",
"bgWarm": "#4a1e6a",
"bgPink": "#35153a",
"comment": "#fede5d",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"red": "#fe4450",
"cyan": "#36f9f6",
"yellow": "#fede5d",
"pink": "#ff7edb",
"green": "#72f1b8",
"orange": "#ff8b39",
"purple": "#c792ea",
"blue": "#4d9de0"
},
"colors": {
"accent": "cyan",
"border": "pink",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "#4a9e6a",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "yellow",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "purple",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "pink",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "red",
"syntaxFunction": "cyan",
"syntaxVariable": "fg",
"syntaxString": "yellow",
"syntaxNumber": "pink",
"syntaxType": "green",
"syntaxOperator": "cyan",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "purple",
"thinkingHigh": "cyan",
"thinkingXhigh": "pink",
"bashMode": "orange"
}
}

View File

@@ -1,83 +0,0 @@
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
"name": "tokyo-night",
"vars": {
"bg": "#1a1b26",
"bgDark": "#141520",
"bg1": "#1e2030",
"bg2": "#252840",
"surface": "#2a2d48",
"selection": "#353860",
"bgRed": "#301420",
"bgOrange": "#2e1e14",
"bgSky": "#162040",
"bgCyan": "#142530",
"bgWarm": "#301848",
"bgPink": "#2d1430",
"comment": "#90e8ff",
"fg": "#ffffff",
"fgSoft": "#bbbbbb",
"blue": "#7eaaff",
"cyan": "#72dfff",
"magenta": "#c9a5ff",
"purple": "#b48ef5",
"green": "#a8e06a",
"red": "#ff7a94",
"orange": "#ffa55c",
"yellow": "#f0c060",
"teal": "#20d4b0"
},
"colors": {
"accent": "blue",
"border": "purple",
"borderAccent": "cyan",
"borderMuted": "surface",
"success": "green",
"error": "red",
"warning": "orange",
"muted": "comment",
"dim": "comment",
"text": "fg",
"thinkingText": "teal",
"selectedBg": "bgPink",
"userMessageBg": "bgWarm",
"userMessageText": "fg",
"customMessageBg": "bgCyan",
"customMessageText": "fg",
"customMessageLabel": "cyan",
"toolPendingBg": "bgOrange",
"toolSuccessBg": "bgSky",
"toolErrorBg": "bgRed",
"toolTitle": "orange",
"toolOutput": "fgSoft",
"mdHeading": "yellow",
"mdLink": "cyan",
"mdLinkUrl": "comment",
"mdCode": "magenta",
"mdCodeBlock": "fgSoft",
"mdCodeBlockBorder": "surface",
"mdQuote": "green",
"mdQuoteBorder": "surface",
"mdHr": "surface",
"mdListBullet": "blue",
"toolDiffAdded": "green",
"toolDiffRemoved": "red",
"toolDiffContext": "comment",
"syntaxComment": "comment",
"syntaxKeyword": "magenta",
"syntaxFunction": "blue",
"syntaxVariable": "purple",
"syntaxString": "green",
"syntaxNumber": "orange",
"syntaxType": "cyan",
"syntaxOperator": "teal",
"syntaxPunctuation": "fgSoft",
"thinkingOff": "surface",
"thinkingMinimal": "comment",
"thinkingLow": "blue",
"thinkingMedium": "cyan",
"thinkingHigh": "magenta",
"thinkingXhigh": "red",
"bashMode": "yellow"
}
}

210
AUDIT.md Normal file
View File

@@ -0,0 +1,210 @@
# JustVitamin Proposal Site — Conversion Audit
**Audited:** justvitamin.quikcue.com (/, /proposal, /offer, /dashboard)
**Data source:** PostgreSQL DB — 728,018 validated orders, Nov 2005 Jan 2026
**Auditor:** Ruthless QA pass — every claim verified against raw data
---
## Start-Today Score: 3/10
**The demos are genuinely impressive but the two CTAs that matter — "Approve & Start Build" and "Let's Go" — are both broken `mailto:` links with NO email address. A client literally cannot say yes.**
---
## Top 10 Fixes (Highest Leverage First)
### Fix #1: BROKEN CTAs — Both conversion buttons are dead links
- **Problem:** `/offer` "Approve & Start Build →" and `/proposal` "Let's Go →" both link to `mailto:` with no email address. They do nothing.
- **Why it kills conversions:** The client reaches the end of the best pitch you'll ever make — and the door is locked. 100% of decision-ready traffic dies here.
- **Exact change:** Replace both with `mailto:omair@quikcue.com?subject=JustVitamin%20—%20Approved%20to%20Start%20Build&body=Hi%20Omair%2C%0A%0AApproved%20to%20proceed.%20Let%27s%20schedule%20the%20kickoff.` AND add a Calendly/Cal.com booking link as primary CTA.
- **Where:** `/offer` line 552, `/proposal` line 1013
- **Effort:** S | **Impact:** CRITICAL
### Fix #2: "97.4% Channel Dependency" claim is WRONG
- **Problem:** The offer page headline claims "Organic + Google Ads = 97.4% of all orders." Actual data shows **85.4% in 2025** (Organic 56.6% + Google Ads 28.8%) or **81.7% all-time**.
- **Why it kills conversions:** If the client checks this against their own Shopify analytics, your entire credibility collapses. One wrong number invalidates all numbers.
- **Exact change:** Replace "97.4%" with "85%" and reframe: "85% of your orders come from just two Google-dependent channels. Facebook is 0.1%. TikTok is 0%. You have almost zero social discovery." — the story is still devastating at 85%.
- **Where:** `/offer` hero stat box, section heading "97% Channel Dependency", channel donut chart, board summary bullet. 4 occurrences.
- **Effort:** S | **Impact:** HIGH
### Fix #3: "37.3% repeat rate" is UNVERIFIED — wrong metric used
- **Problem:** The revenue model uses "Repeat rate = 37.3% (your actual)" but this number cannot be derived from the data. The actual returning-order rate is 68.1% all-time / 86.8% in 2025. The cohort 12-month return rate averages 57.6%.
- **Why it kills conversions:** If 37.3% is wrong, the entire ROI calculator is wrong. The interactive model is the strongest close on the page — it must be bulletproof.
- **Exact change:** Use a verifiable metric: either cohort-based "57.6% of customers return within 12 months" or returning-order-share "68% of all orders are repeat purchases." Then recalculate the ROI model with the correct figure.
- **Where:** `/offer` interactive revenue model, assumptions text
- **Effort:** M | **Impact:** HIGH
### Fix #4: No person, no face, no credibility
- **Problem:** Zero information about who built this. "QuikCue" and "Omair" appear in tiny footer text. No bio, no photo, no LinkedIn, no portfolio, no "why me."
- **Why it kills conversions:** The client is being asked to pay £4,000 to someone with no visible identity. At this price point, they Google you. If they find nothing, they don't buy.
- **Exact change:** Add a "Built by" section with: headshot, name, 2-line bio ("I've built AI systems for X, Y, Z"), LinkedIn link, and 1-2 sentence personal note to the client. Place it before the CTA on `/offer`.
- **Where:** `/offer` before the "Decide" section, `/` above footer
- **Effort:** S | **Impact:** HIGH
### Fix #5: Two competing proposal pages — pick one
- **Problem:** `/proposal` and `/offer` are separate pages covering the same content. `/proposal` is weaker (no data story, no calculator, no de-risk section). A confused client reads both and trusts neither.
- **Why it kills conversions:** Split attention = no action. The client doesn't know which is the "real" proposal.
- **Exact change:** Kill `/proposal`. Redirect to `/offer`. The offer page is the complete pitch. Remove "Proposal" from nav, rename nav link to "The Proposal" pointing at `/offer`.
- **Where:** Navigation bar, `/proposal` route
- **Effort:** S | **Impact:** HIGH
### Fix #6: No pre-generated demo output — visitor must wait 90+ seconds
- **Problem:** All 3 demos start with "Waiting." The visitor must click, then wait 70-90s for AI generation. Most visitors won't wait.
- **Why it kills conversions:** The demo is the proof. If the proof requires patience, it's not proof — it's a promise.
- **Exact change:** Pre-generate one demo output (the D3+K2 product) and display it as the default state. Add a "Try another product" toggle that runs the live demo. The pre-loaded output proves it works; the live toggle proves it's real.
- **Where:** `/` Demo A section
- **Effort:** M | **Impact:** HIGH
### Fix #7: Homepage hero talks to us, not to the client
- **Problem:** "Your content engine is real and running" is about us proving our tech works. It says nothing about the client's problem, pain, or gain.
- **Why it kills conversions:** The client's first 5 seconds should be "they understand my problem." Instead, they get "look what I built."
- **Exact change:** Hero headline: **"JustVitamins has lost 84% of its new customers since 2020. This AI engine gets them back."** Sub: "We analysed your 728,018 orders. The product isn't the problem — discovery is. See the data, see the engine, decide today."
- **Where:** `/` hero section (h1 + subtitle)
- **Effort:** S | **Impact:** HIGH
### Fix #8: No "cost of doing nothing" visualisation
- **Problem:** The "£5,000£10,000 per month" cost-of-waiting claim is an ASSUMPTION with no derivation shown. It's presented as data but is actually a guess.
- **Why it kills conversions:** Savvy buyers spot unsubstantiated urgency and distrust the rest.
- **Exact change:** Replace with a verifiable projection: "In 2020, you acquired 24,666 new customers. In 2025, just 3,941. At your current AOV of £35.02, that's **£726,000 in lost first-purchase revenue per year** — before repeat purchases." Show the math inline. This is SOURCE-LINKED and devastating.
- **Where:** `/offer` cost-of-waiting callout
- **Effort:** S | **Impact:** MED
### Fix #9: No before/after proof of AI quality
- **Problem:** The demos generate output live, but there's no screenshot or example showing "Here's what your PDP looks like now → Here's what it looks like after AI." The client can't visualise the transformation without running the demo.
- **Why it kills conversions:** Before/after is the #1 conversion mechanic in any transformation pitch. It's completely absent.
- **Exact change:** Add a 2-column "Before → After" screenshot block below Demo A. Left: actual justvitamins.co.uk PDP (screenshotted). Right: AI-generated PDP output (screenshotted from the demo). Static images, instant load.
- **Where:** `/` between Demo A and Demo B
- **Effort:** M | **Impact:** MED
### Fix #10: Revenue model "payback period" math is wrong
- **Problem:** The calculator shows "2.6 mo" payback at 100 new customers/month, but the actual math gives 3.5 months (£12,400 / £3,502 per month). The 2.6 figure seems to include repeat revenue in month 1, which hasn't happened yet.
- **Why it kills conversions:** If the client runs the numbers themselves and gets a different answer, trust dies.
- **Exact change:** Use first-purchase-only for payback: 3.5 months at 100/mo, 7 months at 50/mo. Show the formula visibly. Add a note: "Repeat purchases improve ROI further in months 4-12 but are excluded from payback calculation."
- **Where:** `/offer` interactive revenue model
- **Effort:** S | **Impact:** MED
---
## Trust & Data Audit Report
### Hard Claims Table
| # | Claim | Page | Actual Value | Status | Action |
|---|-------|------|-------------|--------|--------|
| 1 | £19.4M lifetime revenue | / hero | £19,417,899 | ✅ SOURCE-LINKED | Keep |
| 2 | 728K orders processed | / hero | 728,018 | ✅ SOURCE-LINKED | Keep |
| 3 | 230K unique customers | / hero | 230,651 | ✅ SOURCE-LINKED | Keep |
| 4 | 20 years trading history | / hero | Nov 2005 Jan 2026 (20.2 yrs) | ✅ SOURCE-LINKED | Keep |
| 5 | -84% new customer decline | /offer hero | -84.0% (24,666→3,941, 2020→2025) | ✅ SOURCE-LINKED | Keep |
| 6 | -42% revenue from peak | /offer hero | -42.5% (£1.82M→£1.05M) | ✅ SOURCE-LINKED | Keep |
| 7 | 97.4% channel dependency (Google+Organic) | /offer hero + 3 more | 85.4% (2025) / 81.7% (all-time) | ❌ WRONG | **Fix to 85%** |
| 8 | AOV climbed from £26→£35 | /offer data | £26.46 (2018)→£35.02 (2025) | ✅ SOURCE-LINKED | Keep, add years |
| 9 | Repeat rate 37% / 37.3% | /offer model | Cannot verify. Returning rate=68.1%, cohort=57.6% | ⚠️ UNVERIFIED | **Fix: use verifiable metric** |
| 10 | 24,600/year in 2020 new customers | /offer data | 24,666 | ✅ SOURCE-LINKED | Keep |
| 11 | Under 4,000 in 2025 new customers | /offer data | 3,941 | ✅ SOURCE-LINKED | Keep |
| 12 | Facebook: 0.1% | /offer channel | 694/728,018 = 0.10% (all-time), 34/29,919 = 0.11% (2025) | ✅ SOURCE-LINKED | Keep |
| 13 | TikTok: 0%, Instagram: 0% | /offer channel | Not present in channel data | ✅ DERIVED (absence=0) | Keep |
| 14 | £5,000£10,000/month cost of waiting | /offer callout | Requires 143-286 new social customers/month. No basis for this range. | ❌ UNVERIFIED | **Replace with verifiable calc** |
| 15 | AOV = £35.02 (2025 actual) | /offer model | £35.02 | ✅ SOURCE-LINKED | Keep |
| 16 | "Competitors producing 10x content" | /offer | No source or evidence | ❌ UNVERIFIED | **Remove or soften** |
| 17 | Year 1 cost = £12,400 | /offer model | £4,000 + £500×12 + £200×12 = £12,400 | ✅ DERIVED (arithmetic) | Keep |
| 18 | 5.9x Year 1 ROI at 100 custs/mo | /offer model | Depends on 37.3% repeat rate being correct | ⚠️ CONDITIONAL | **Recalculate with verified rate** |
| 19 | 2.6 month payback | /offer model | Actual: 3.5 months (first-purchase only) | ❌ WRONG | **Fix math** |
| 20 | Revenue peak £1.82M | /offer board summary | £1,820,963 | ✅ SOURCE-LINKED | Keep |
| 21 | Revenue 2025 £1.05M | /offer board summary | £1,047,850 | ✅ SOURCE-LINKED | Keep |
| 22 | 3,900/year new customers 2025 | /offer board summary | 3,941 | ✅ SOURCE-LINKED | Keep (round to 3,900 is fair) |
| 23 | 728,018 validated orders | /offer footer | 728,018 | ✅ SOURCE-LINKED | Keep |
**Summary: 15/23 claims verified, 4 wrong/unverified, 4 conditional.**
---
## Rewritten "Start Today" Block
### Current (broken):
```
Ready to Build?
If approved, access is provided and build starts immediately.
[Approve & Start Build →] ← links to mailto: (empty!)
Build begins within 48 hours of approval.
```
### Rewritten:
```
──────────────────────────────────────
YOU'VE SEEN THE DATA. YOU'VE SEEN THE ENGINE.
Every month without social discovery costs you
£60,000+ in lost new-customer revenue.
(20,700 fewer new customers × £35.02 AOV = £726K/year lost since 2020)
THE OFFER:
✦ £4,000 one-time build — all 4 pillars
✦ £500/month infrastructure — cancel with 30 days' notice
✦ Week 4 gate — full review before any ongoing commitment
✦ You own everything — server, code, content, data
RISK REVERSAL:
→ If you're not satisfied at Week 4, walk away. No ongoing fees.
→ The £4,000 build cost delivers real infrastructure you keep regardless.
→ 30-day monthly exit clause. No lock-in. No agency dependency.
TO START:
□ 1. Reply to this email confirming approval
□ 2. We'll send Shopify collaborator access request
□ 3. 15-min kickoff call within 48 hours
□ 4. Infrastructure live by end of Week 1
[ Book 15-Min Kickoff Call → ] ← Calendly link
[ Reply: Approved to Start → ] ← mailto:omair@quikcue.com?subject=...
Built by Omair @ QuikCue
──────────────────────────────────────
```
**Key changes:**
1. Opens with data-backed cost of inaction (verifiable)
2. Offer summarised in 4 bullets (not buried in sections)
3. Risk reversal is explicit and bold
4. Two CTA options: low-friction (Calendly) + decisive (email)
5. Steps are numbered and tiny (3 things, nothing scary)
6. Person identified by name
---
## Detailed Verification Notes
### The 97.4% problem
The site claims "Organic + Google Ads = 97.4% of all orders." The actual channel breakdown:
**2025:**
| Channel | Orders | Share |
|---------|--------|-------|
| Organic | 16,942 | 56.6% |
| Google Adwords | 8,620 | 28.8% |
| Webgains | 2,768 | 9.3% |
| Email Newsletter | 1,402 | 4.7% |
| Bing | 153 | 0.5% |
| Facebook | 34 | 0.1% |
| **Total** | **29,919** | **100%** |
Google + Organic = **85.4%**, not 97.4%. Even adding Bing = 85.9%. Even adding Webgains = 95.2%. None of these groupings produce 97.4%.
**Recommended reframe:** "85% of orders depend on Google channels. 0.1% come from social. You have zero TikTok, zero Instagram, zero YouTube presence." — This is true AND just as alarming.
### The 37.3% repeat rate problem
No combination of available data produces 37.3%:
- Returning orders / total orders = 68.1% (all-time)
- Returning orders / total orders = 86.0% (2025)
- Average 12-month cohort retention = 57.6%
- New customer share = 33.5% (all-time)
37.3% might have come from a different analysis of the raw jv_data.json before aggregation, but it's not reproducible from the deployed database. The revenue model should use the cohort-verified 57.6% or explain its source.
### The payback period problem
At 100 new customers/month, monthly first-purchase revenue = £3,502.
Year 1 cost = £12,400.
Payback on first-purchase revenue alone = 12,400 / 3,502 = **3.54 months**, not 2.6.
The 2.6 month figure likely includes repeat revenue from the first cohorts, which is optimistic for a payback calculation. Standard practice uses first-purchase only.

View File

@@ -1,20 +0,0 @@
# Pi vs CC — Extension Playground
Pi Coding Agent extension examples and experiments.
## Tooling
- **Package manager**: `bun` (not npm/yarn/pnpm)
- **Task runner**: `just` (see justfile)
- **Extensions run via**: `pi -e extensions/<name>.ts`
## Project Structure
- `extensions/` — Pi extension source files (.ts)
- `specs/` — Feature specifications
- `.pi/agents/` — Agent definitions for agent-team extension
- `.pi/agent-sessions/` — Ephemeral session files (gitignored)
## Conventions
- Extensions are standalone .ts files loaded by Pi's jiti runtime
- Available imports: `@mariozechner/pi-coding-agent`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`, `@sinclair/typebox`, plus any deps in package.json
- Register tools at the top level of the extension function (not inside event handlers)
- Use `isToolCallEventType()` for type-safe tool_call event narrowing

195
application-answers.md Normal file
View File

@@ -0,0 +1,195 @@
# 🎯 Omair Saleh — Full-Stack Engineer Application @ Calvana LTD
## The Outlaw Application
---
## Field 1: Full Name
```
Omair Saleh
```
## Field 2: Email Address
```
omair@quikcue.com
```
## Field 3: LinkedIn / Personal Site / Portfolio
```
https://www.linkedin.com/in/omair-rescues/
```
> 💡 If quikcue.com is live, use that. Custom domain > LinkedIn every time.
## Field 4: GitHub or Equivalent
```
[Your GitHub URL here]
```
> 💡 Pin the charity platform, the Hub, and the outreach agent. Let the commit history speak.
## Field 5: Location
```
Kuala Lumpur, Malaysia — happy to overlap with London hours. I work when the work needs doing, not when a calendar tells me to.
```
## Field 6: Employment Status
> **Select: "Running my own thing"**
---
## Field 7: 🔥 "Describe something you built end-to-end"
```
A UK charity came to me with a problem: their donation flow was bleeding donors. Poor conversion, no recurring giving, no peer-to-peer fundraising, no Gift Aid automation. They didn't hand me a spec. There was no spec. There was a problem and a deadline.
So I built the whole thing. From the database schema to the Stripe webhook handlers.
Next.js 15 frontend. PostgreSQL with Prisma. Stripe for payments — PaymentIntents for one-off, SetupIntents for recurring. I designed a multi-step checkout with progressive disclosure because I know that every extra field before the payment button is a donor you'll never see again.
Nobody told me to handle Zakat compliance. I just knew that if a Muslim donor selects Zakat, admin fees need to auto-disable — it's a religious obligation, not a suggestion. So I built it. Nobody told me to move Gift Aid capture to post-payment either. But I knew that asking a donor for their home address BEFORE they've committed to giving is how you kill conversion. So I moved it. HMRC still gets what they need. The charity gets more donations. Problem solved.
Then I built the P2P fundraising engine — individual pages, team pages, leaderboards, URL-based attribution — architected as its own domain service because I could see it would need to scale independently. Then an admin dashboard. Then a Chatwoot integration for donor support, white-labeled with a Chrome extension I wrote because the dev workflow needed it. Then a data sync pipeline using Playwright to scrape donor CSVs from LaunchGood and reconcile them into Postgres with strict deduplication.
No PM. No Jira board. No sprint ceremonies. Just me, the problem, and the production environment.
This is what I do. I see a mess, I build the system, I ship it. In a corporate environment, this gets me in trouble — I've been told I "move too fast", I "don't follow process", I "should wait for alignment." At a startup, this is the only speed that matters.
```
---
## Field 8: Link to Something You've Built
```
[Link to your charity donation platform or best GitHub repo]
```
---
## Field 9: 🔥 AI/ML API Experience
```
I don't prototype with AI. I ship with it. There's a difference.
1. AI Outreach Agent: A charity needed to find and contact decision-makers across the entire UK charity sector. Hundreds of thousands of records. I built a Python pipeline that ingests raw Charity Commission data into PostgreSQL, then uses OpenAI to translate natural language queries ("large education charities with income over £1M operating nationally") into SQL filter logic via a custom segment engine. Once leads are qualified, OpenAI generates personalised outreach assets — emails, talking points — based on each charity's actual profile, income band, and sector. Not templated mail-merge garbage. Actually personalised. Then it enriches contacts through Apify to find the CEO, Director, or Head of Fundraising. The whole thing runs from a CLI with deterministic Python scripts underneath — the AI makes decisions, but the infrastructure is boring and reliable. On purpose.
2. Conversation Intelligence (Hub Platform): Built into a B2B customer service platform. When a support agent opens a Chatwoot conversation, the system pulls the customer's order history from Salla, their previous interactions, and uses OpenAI with structured function calling to suggest contextual responses grounded in real data. Not vibes-based autocomplete — actual responses that reference real order numbers and real product names. I built it this way because I've seen what happens when you let AI hallucinate in customer-facing contexts. It destroys trust instantly.
3. AI Command Center: This one's borderline unhinged. An autonomous multi-agent system that runs on a 15-minute cron cycle. Reliability agent monitors Sentry. Code-steward reviews MRs on GitLab. Product-driver agent analyses codebase health metrics from Postgres/MySQL and proposes improvements. But — and this is the part that matters — nothing executes without human approval. I built a full safety layer with auto-pause on excessive API spend, command allowlists, and dry-run mode. Because I learned early that autonomous AI without kill switches is just a very expensive way to break production.
The real lesson across all of these: the API call is the easy part. The hard part is building the deterministic scaffolding that makes AI trustworthy — retry logic, structured outputs, cost ceilings, caching layers, human-in-the-loop gates. Anyone can call OpenAI. I build the systems that make it safe to let OpenAI call the shots.
```
---
## Field 10: Tech Skills Rating
| Technology | Select This |
|---|---|
| **React / Next.js** | **production-level experience** |
| **Python / Django** | **strong experience** |
| **PostgreSQL** | **production-level experience** |
| **AWS** | **decent experience** |
| **REST API design & integrations** | **production-level experience** |
| **OAuth** | **strong experience** |
| **CI/CD & Deployment Pipelines** | **strong experience** |
| **Docker / containerisation** | **strong experience** |
> Don't inflate. Let the project descriptions do the talking. Honesty here builds trust for everything else.
---
## Field 11: 🔥 "Why does this role interest you specifically?"
```
I'll be honest: I'm a terrible employee.
Not in the way you'd think. I ship fast, I write clean code, I own my systems end-to-end. But I've learned the hard way that I don't survive in environments where shipping requires three meetings, two approvals, and a Confluence page nobody reads. I've been told I "go rogue." I've been told I "need to wait for the team to align." I've sat in sprint planning sessions thinking about the three features I could've shipped in the time it took to estimate the story points.
That's not a personality flaw. It's a misallocation.
Your job post reads like someone wrote it specifically for people like me. "This isn't a role where you'll have a dedicated PM writing specs." Good — I've never needed one. "This isn't a role where 'that's not my job' is a useful phrase." I literally built a Chrome extension because my dev workflow for a Chatwoot integration was annoying me. Nobody asked me to. The friction existed, so I killed it.
But here's what actually made me stop scrolling and pay attention:
You have cash, audience, distribution, and PMF. You DON'T have engineers. That's the most dangerous inflection point for a startup — the gap between "this works" and "this scales." That gap gets filled by someone who can pick up an entire problem, architect a solution, ship it as a microservice, and move on to the next one without waiting for permission. I've been doing exactly that for the past year: a full donation platform with Stripe, P2P, and Gift Aid compliance. A multi-service B2B operations hub with 30+ services, AI automation, and real-time event processing. An outreach engine that processes hundreds of thousands of leads with AI. All end-to-end. All without a PM.
Your stack is my stack — Next.js, Python, PostgreSQL, Stripe, OAuth, Docker. Your AI ambitions are things I've already built. Your microservices architecture is how I think.
I watched Charlie's Loom. "We're going to the moon with this thing." I believe it. And I know that the difference between going to the moon and talking about going to the moon is having someone in the engine room who builds without asking for permission.
That's me. I'm the guy in the engine room.
```
---
## Field 12: Salary Expectation
```
£50,000£65,000 GBP/year — flexible on structure. If the equity conversation is real, I'm more interested in upside than ceiling.
```
---
## Field 13: How soon could you start?
> **Select: "Immediately"** — you're running your own thing, you set your own timeline.
---
## Field 14: 🔥 Loom Video Script (THE KNOCKOUT PUNCH)
```
[0:00-0:20]
"Hey Charlie — I'm Omair. I'll be straight with you: I'm a terrible fit
for most companies. I've been told I move too fast, I don't wait for
alignment, I build things nobody asked for. Turns out those are
features, not bugs — just depends on the environment. Your job post
reads like it was written for someone exactly like me."
[0:20-0:55] [SCREEN SHARE: Charity donation platform]
"Quick example. A UK charity had a broken donation flow. No spec, no PM,
no Jira. Just a problem. So I built this — end to end. Next.js 15,
Prisma, PostgreSQL, Stripe. Multi-step checkout, recurring giving, P2P
fundraising, Zakat compliance, Gift Aid for HMRC. Designed the schema,
wrote the webhook handlers, deployed it. That's how I work — give me the
problem, get out of the way."
[0:55-1:25] [SCREEN SHARE: Hub platform or AI outreach agent]
"Then there's this — an AI outreach engine I built. Ingests hundreds of
thousands of charity records, uses OpenAI to segment and qualify leads,
generates personalised outreach. The AI is wrapped in deterministic
Python with cost controls and approval gates — because I've learned that
AI without guardrails is just an expensive way to break things."
[1:25-1:50]
"Your post said 'we have cash, audience, distribution, and PMF — we just
need YOU.' I felt that. I've spent the last year building entire systems
solo — the donation platform, a B2B SaaS hub with 30+ microservices, AI
agents running on cron cycles. No PM, no sprint ceremonies. Just
problems and production. That's the only way I know how to work — and
it sounds like that's exactly what you need."
[1:50-2:00]
"I don't need onboarding. I need a problem and a git repo. Let's talk."
```
---
## ⚡ PRE-SUBMIT CHECKLIST
- [ ] GitHub pinned repos updated and READMEs are clean
- [ ] LinkedIn headline: "Full-Stack Engineer | I build things nobody asked for"
- [ ] All answers proofread — raw ≠ sloppy
- [ ] Loom recorded — show real projects, show real energy, close hard
- [ ] quikcue.com email shows you're a founder, not an applicant
---
## 🎭 THE OUTLAW POSITIONING — WHY THIS WORKS
The entire job posting is a filter for people who **can't survive in corporate**:
| What Their Post Says | What It Actually Means | Your Outlaw Angle |
|---|---|---|
| "No PM writing specs for you" | We need self-starters | "I've never needed a PM. I AM the PM." |
| "Not just one part of the codebase" | Generalists only | "I built frontend, backend, infra, Chrome extensions, data pipelines — in one project." |
| "'That's not my job' isn't useful" | Ego-free builders | "I built a Chrome extension because a workflow annoyed me. Nobody asked." |
| "Ambiguity of early-stage work" | Chaos tolerance required | "Chaos is where I do my best work. Structure is where I suffocate." |
| "No AI screening — we read every app" | Charlie reads this personally | You're speaking directly to a founder. Be human. Be direct. |
**The core message in every answer:** *The things that make me a liability in corporate make me your most valuable hire. I don't wait for permission. I don't need process. I see problems and I ship solutions. That's why big companies don't know what to do with me — and it's exactly why you should.*

BIN
audit-demos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
audit-footer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

BIN
audit-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

BIN
audit-offer-bottom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
audit-offer-mid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

BIN
audit-offer-mid2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
audit-offer-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
audit-proposal-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

BIN
auth-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
auth-success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

BIN
auth-wrong.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -5,24 +5,115 @@
"": {
"name": "pi-vs-cc",
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"better-sqlite3": "^12.6.2",
"yaml": "^2.8.0",
},
"devDependencies": {
"@playwright/cli": "^0.1.1",
"@types/better-sqlite3": "^7.6.13",
},
},
},
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.78.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-PzQhR715td/m1UaaN5hHXjYB8Gl2lF9UVhrrGrZeysiF6Rb74Wc9GCB8hzLdzmQtBd1qe89F9OptgB9Za1Ib5w=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@playwright/cli": ["@playwright/cli@0.1.1", "", { "dependencies": { "minimist": "^1.2.5", "playwright": "1.59.0-alpha-1771104257000" }, "bin": { "playwright-cli": "playwright-cli.js" } }, "sha512-9k11ZfDwAfMVDDIuEVW1Wvs8SoDNXIY1dNQ+9C9/SS8ZmElkcxesu5eoL7vNa96ntibUGaq1TM2qQoqvdl/I9g=="],
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"playwright": ["playwright@1.59.0-alpha-1771104257000", "", { "dependencies": { "playwright-core": "1.59.0-alpha-1771104257000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-6SCMMMJaDRsSqiKVLmb2nhtLES7iTYawTWWrQK6UdIGNzXi8lka4sLKRec3L4DnTWwddAvCuRn8035dhNiHzbg=="],
"playwright-core": ["playwright-core@1.59.0-alpha-1771104257000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-YiXup3pnpQUCBMSIW5zx8CErwRx4K6O5Kojkw2BzJui8MazoMUDU6E3xGsb1kzFviEAE09LFQ+y1a0RhIJQ5SA=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
}
}

View File

@@ -0,0 +1,12 @@
{
"name": "calvana",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server/index.js"
},
"dependencies": {
"express": "^4.21.0",
"pg": "^8.13.0"
}
}

View File

@@ -0,0 +1,153 @@
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const app = express();
app.use(express.json());
const pool = new Pool({
host: process.env.DB_HOST || 'dokploy-postgres',
port: parseInt(process.env.DB_PORT || '5432'),
user: process.env.DB_USER || 'dokploy',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'calvana',
});
// Health check
app.get('/api/health', async (req, res) => {
try {
await pool.query('SELECT 1');
res.json({ status: 'ok', db: 'connected' });
} catch (e) {
res.status(500).json({ status: 'error', db: e.message });
}
});
// GET ships
app.get('/api/ships', async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM ships ORDER BY created_at DESC'
);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST ship
app.post('/api/ships', async (req, res) => {
const { title, status, metric, details } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
try {
const { rows } = await pool.query(
'INSERT INTO ships (title, status, metric, details) VALUES ($1, $2, $3, $4) RETURNING *',
[title, status || 'planned', metric || null, details || null]
);
res.status(201).json(rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// PATCH ship
app.patch('/api/ships/:id', async (req, res) => {
const { id } = req.params;
const { title, status, metric, details } = req.body;
try {
const sets = [];
const vals = [];
let i = 1;
if (title !== undefined) { sets.push(`title=$${i++}`); vals.push(title); }
if (status !== undefined) { sets.push(`status=$${i++}`); vals.push(status); }
if (metric !== undefined) { sets.push(`metric=$${i++}`); vals.push(metric); }
if (details !== undefined) { sets.push(`details=$${i++}`); vals.push(details); }
if (sets.length === 0) return res.status(400).json({ error: 'nothing to update' });
sets.push(`updated_at=NOW()`);
vals.push(id);
const { rows } = await pool.query(
`UPDATE ships SET ${sets.join(', ')} WHERE id=$${i} RETURNING *`,
vals
);
if (rows.length === 0) return res.status(404).json({ error: 'not found' });
res.json(rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// DELETE ship
app.delete('/api/ships/:id', async (req, res) => {
const { id } = req.params;
try {
const { rows } = await pool.query(
'DELETE FROM ships WHERE id=$1 RETURNING *',
[id]
);
if (rows.length === 0) return res.status(404).json({ error: 'not found' });
res.json({ deleted: true, ship: rows[0] });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// DELETE oops
app.delete('/api/oops/:id', async (req, res) => {
const { id } = req.params;
try {
const { rows } = await pool.query(
'DELETE FROM oops WHERE id=$1 RETURNING *',
[id]
);
if (rows.length === 0) return res.status(404).json({ error: 'not found' });
res.json({ deleted: true, oops: rows[0] });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// GET oops
app.get('/api/oops', async (req, res) => {
try {
const { rows } = await pool.query(
'SELECT * FROM oops ORDER BY created_at DESC'
);
res.json(rows);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// POST oops
app.post('/api/oops', async (req, res) => {
const { description, fix_time, commit_link } = req.body;
if (!description) return res.status(400).json({ error: 'description required' });
try {
const { rows } = await pool.query(
'INSERT INTO oops (description, fix_time, commit_link) VALUES ($1, $2, $3) RETURNING *',
[description, fix_time || null, commit_link || null]
);
res.status(201).json(rows[0]);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Serve static files
app.use(express.static(path.join(__dirname, '..', 'html')));
// SPA fallback — serve index.html for unmatched routes
app.get('*', (req, res) => {
// Check if requesting a known page directory
const pagePath = path.join(__dirname, '..', 'html', req.path, 'index.html');
const fs = require('fs');
if (fs.existsSync(pagePath)) {
return res.sendFile(pagePath);
}
res.sendFile(path.join(__dirname, '..', 'html', 'index.html'));
});
const PORT = process.env.PORT || 80;
app.listen(PORT, '0.0.0.0', () => {
console.log(`Calvana server listening on :${PORT}`);
});

BIN
data/agents.db Normal file

Binary file not shown.

BIN
data/agents.db-shm Normal file

Binary file not shown.

BIN
data/agents.db-wal Normal file

Binary file not shown.

54
data/bot.log Normal file
View File

@@ -0,0 +1,54 @@
🤖 Telegram Agent Orchestrator starting...
Polling for updates...
The system cannot find the path specified.
The system cannot find the path specified.
[agent 1] error: 50 | }
51 | if (status === 422) {
52 | return new UnprocessableEntityError(status, error, message, headers);
53 | }
54 | if (status === 429) {
55 | return new RateLimitError(status, error, message, headers);
^
error: 429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your organization's rate limit of 10,000 input tokens per minute (org: 4362d07d-8082-4159-b447-7c9f0172030e, model: claude-sonnet-4-20250514). For details, refer to: https://docs.claude.com/en/api/rate-limits. You can see the response headers for current usage. Please reduce the prompt length or the maximum tokens requested, or try again later. You may also contact sales at https://www.anthropic.com/contact-sales to discuss your options for a rate limit increase."},"request_id":"req_011CYeuG3V1tiXgYoBie69XF"}
status: 429,
headers: Headers {
"date": "Mon, 02 Mar 2026 20:41:30 GMT",
"content-type": "application/json",
"transfer-encoding": "chunked",
"connection": "keep-alive",
"strict-transport-security": "max-age=31536000; includeSubDomains; preload",
"content-encoding": "gzip",
"vary": "Accept-Encoding",
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
"x-should-retry": "true",
"anthropic-ratelimit-input-tokens-limit": "10000",
"anthropic-ratelimit-input-tokens-remaining": "0",
"anthropic-ratelimit-input-tokens-reset": "2026-03-02T20:42:31Z",
"anthropic-ratelimit-output-tokens-limit": "4000",
"anthropic-ratelimit-output-tokens-remaining": "4000",
"anthropic-ratelimit-output-tokens-reset": "2026-03-02T20:41:30Z",
"anthropic-ratelimit-requests-limit": "5",
"anthropic-ratelimit-requests-remaining": "0",
"anthropic-ratelimit-requests-reset": "2026-03-02T20:42:28Z",
"retry-after": "11",
"anthropic-ratelimit-tokens-limit": "14000",
"anthropic-ratelimit-tokens-remaining": "4000",
"anthropic-ratelimit-tokens-reset": "2026-03-02T20:41:30Z",
"request-id": "req_011CYeuG3V1tiXgYoBie69XF",
"anthropic-organization-id": "4362d07d-8082-4159-b447-7c9f0172030e",
"server": "cloudflare",
"x-envoy-upstream-service-time": "1023",
"cf-cache-status": "DYNAMIC",
"x-robots-tag": "none",
"cf-ray": "9d6338f76a681ec5-KUL",
},
requestID: "req_011CYeuG3V1tiXgYoBie69XF",
error: {
type: "error",
error: [Object ...],
request_id: "req_011CYeuG3V1tiXgYoBie69XF",
},
at generate (C:\Users\uldvs\OneDrive\Desktop\work\pi-agent-improved-main\node_modules\@anthropic-ai\sdk\core\error.mjs:55:20)
at makeRequest (C:\Users\uldvs\OneDrive\Desktop\work\pi-agent-improved-main\node_modules\@anthropic-ai\sdk\client.mjs:309:30)

BIN
debug-2550.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

BIN
final-beforeafter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
final-dashboard-2020.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
final-dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
final-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

BIN
final-offer-builtby.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
fix-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

BIN
fix-offer-channel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

BIN
fix-offer-cta.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
fix-offer-hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
fix-offer-model.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

88
gen_hero.py Normal file
View File

@@ -0,0 +1,88 @@
"""Generate a world-class hero image for Pledge Now, Pay Later."""
import sys, io, os, time
sys.stdout.reconfigure(encoding='utf-8')
from google import genai
from google.genai import types
from PIL import Image
client = genai.Client(api_key="AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E")
MODEL = "gemini-3-pro-image-preview"
OUT_DIR = "pledge-now-pay-later/public/images/landing"
BRAND_DIR = "pledge-now-pay-later/brand/photography"
PROMPTS = [
# Concept 1: Phone notification at gala
"""Photorealistic close-up documentary photograph.
A woman's hand holding a smartphone at a charity gala dinner table. The phone screen glows bright showing a green checkmark payment confirmation notification. Her hand is in sharp focus.
Background: beautifully soft bokeh of warm golden tungsten chandelier lights, round dinner tables with white tablecloths, blurred guests in formal attire. A glass of water and edge of a plate visible on the dark wooden table.
British South Asian woman, dark navy blazer sleeve, simple gold bracelet on wrist.
Shot on Sony A7IV, 50mm f/1.4, available warm tungsten light. Extremely shallow depth of field. Documentary candid style, warm color temperature.
The mood: quiet triumph. The pledge came through. Money in the bank.
Portrait orientation, 4:5 aspect ratio. Professional editorial photography.""",
# Concept 2: Dashboard laptop at desk after event
"""Photorealistic documentary photograph of a charity manager's desk, end of a successful fundraising evening.
An open MacBook showing a dashboard with bright green progress bars at 100 percent and payment confirmations. The laptop screen glows in the dim warm light. A phone beside it shows a WhatsApp message. A cup of tea, reading glasses folded on the desk.
The setting is a quiet office after an event. Warm desk lamp casting golden light, a window showing evening London skyline with city lights in the far background, completely out of focus.
British South Asian woman in her 40s, slight smile, looking at the laptop screen, only her silhouette partially visible from the side. Not looking at camera.
Shot on Leica Q2, 28mm f/1.7, available warm lamp light and blue window light. Shallow depth of field. Documentary candid style.
The mood: satisfied relief. Every pledge tracked. Every penny accounted for.
Portrait orientation, 4:5 aspect ratio. Professional editorial photography.""",
# Concept 3: The green glow moment
"""Photorealistic documentary photograph capturing the exact moment of success.
A close-up of a smartphone in a woman's hand, the screen casting a soft green glow on her face from below. She is standing at the edge of a busy charity gala ballroom. The phone shows a payment dashboard with multiple green indicators.
Background: a sweeping view of a London hotel ballroom with crystal chandeliers creating beautiful warm bokeh circles. Guests at round tables, energy and generosity in the air. All beautifully blurred.
British woman wearing a dark structured blazer, hijab, professional. She holds the phone at mid-chest level, glancing down at it with a subtle knowing expression. Candid, not posed.
Shot on Canon R5, 85mm f/1.2, warm tungsten available light. Extremely shallow depth of field. The phone and her nearest hand are razor sharp, face slightly soft, background completely dissolved into warm golden bokeh circles.
Cinematic documentary photography. The feeling: this is what success looks like. Quiet. Precise. Money in the bank.
Portrait orientation, 4:5 aspect ratio. Professional editorial photography.""",
]
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(BRAND_DIR, exist_ok=True)
results = []
for i, prompt in enumerate(PROMPTS):
for attempt in range(3):
try:
print(f"\n--- Generating concept {i+1}/3 (attempt {attempt+1}) ---")
response = client.models.generate_content(
model=MODEL,
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE", "TEXT"],
temperature=1.0,
),
)
found = False
for part in response.candidates[0].content.parts:
if part.inline_data and part.inline_data.mime_type.startswith("image/"):
img = Image.open(io.BytesIO(part.inline_data.data))
fname = f"hero-concept-{i+1}.jpg"
path = os.path.join(OUT_DIR, fname)
if img.mode != "RGB":
img = img.convert("RGB")
img.save(path, "JPEG", quality=92, optimize=True)
sz = os.path.getsize(path)
print(f" OK {fname} -- {img.size[0]}x{img.size[1]}, {sz//1024}KB")
results.append((fname, img.size, sz))
found = True
break
if found:
break
else:
print(" No image in response, retrying...")
except Exception as e:
emsg = str(e).encode('ascii', 'replace').decode('ascii')
print(f" Error: {emsg}")
if attempt < 2:
time.sleep(5)
print(f"\n=== Generated {len(results)}/3 hero concepts ===")
for fname, size, sz in results:
print(f" {fname}: {size[0]}x{size[1]}, {sz//1024}KB")

144
gen_kaffarah.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Generate 3 on-brand kaffarah-community.jpg replacements.
Uses anti-AI prompting strategy from cr-brand-style.json.
"""
import sys, io, os, time, json
sys.stdout.reconfigure(encoding='utf-8')
from google import genai
from google.genai import types
from PIL import Image
API_KEY = "AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E"
MODEL = "gemini-3-pro-image-preview"
OUT = "screenshots/kaffarah-replacements"
os.makedirs(OUT, exist_ok=True)
client = genai.Client(api_key=API_KEY)
# ── ANTI-AI STYLE BLOCK ──
# No camera names, no "golden hour", no emotion words, no "bokeh".
# Describe PHYSICS of light, SPECIFIC objects, ACTIONS not feelings.
STYLE = (
"Raw documentary photograph with visible film grain and slight color noise in the shadows. "
"The color is MUTED — pulled-back saturation, faded dusty quality, blacks slightly lifted, "
"NOT warm amber or orange-tinted. Highlights have a faint yellow-green cast. "
"Shadows lean cool and neutral. "
"Single hard directional light source creating deep shadows on one side. "
"Unlit areas stay DARK, not filled in. "
"The framing is IMPERFECT — a partial figure or object intrudes at one edge of the frame. "
"The subject is slightly off-center. There is foreground obstruction. "
"Skin has visible pores, uneven tone, slight dust. Hair is uncombed. "
"Clothing is thin worn cotton, faded, creased, with visible stitching. "
"The environment is CLUTTERED with real objects at multiple depth planes. "
"NO smooth skin. NO perfect composition. NO warm glow. NO clean backgrounds. "
"Aspect ratio: exactly 2:1 wide landscape. "
)
PROMPTS = {
"kaffarah-v1.jpg": (
STYLE +
"Three children sit on a swept-dirt floor against a crumbling plastered wall eating from "
"shared metal thali plates. A boy around 8 is mid-chew, mouth slightly open, looking "
"sideways at something outside the frame. His faded blue cotton kurta has a torn collar. "
"Next to him a younger girl scoops rice with her right hand, not looking up. Her hair is "
"tangled and hasn't been brushed. Behind them, a wooden charpai with sagging rope webbing, "
"a plastic water jug, and a torn calendar hanging crooked on the wall. Afternoon sun comes "
"through a doorway on the left, casting a hard beam across the floor — the far wall stays "
"in deep shadow. Someone's bare foot and ankle are visible at the bottom-right edge of the "
"frame, cropped off. Dust motes visible in the light beam. The floor has a cracked cement "
"patch and a worn woven mat. Muted desaturated color with visible grain."
),
"kaffarah-v2.jpg": (
STYLE +
"A boy around 6 sits at a rough wooden bench eating rice and dal from a dented steel plate. "
"His hand is in the food, fingers pressing rice together. He is looking down at his plate, "
"not at the camera. His dark hair sticks up on one side where he slept on it. He wears a "
"faded brown cotton shirt buttoned wrong — one side hangs lower than the other. His "
"fingernails have dirt under them. The bench has deep scratches and a water ring stain. "
"Behind him, a plastered wall with a long crack running diagonally, a nail with nothing "
"on it, and a small high window letting in a hard shaft of light from the right. Two "
"other children are visible in the mid-ground, slightly out of focus, also eating. "
"An adult's elbow and forearm in a grey cotton sleeve intrudes into the left edge of "
"frame. On the bench next to the boy: a scratched steel cup with water. The light "
"illuminates only the right side of his face. The left side falls into shadow. "
"Muted color, visible grain, slight chromatic aberration at contrast edges."
),
"kaffarah-v3.jpg": (
STYLE +
"Seen from slightly behind and to the side of a woman in a faded cream dupatta who is "
"setting down a metal plate of food in front of a small child seated on a woven mat. "
"We see the woman's hands and forearms — veins visible, a thin glass bangle on one wrist. "
"The child, about 5, reaches for the plate with both small hands. The child's face is in "
"three-quarter profile, slightly blurred because the focus is on the hands and the plate. "
"The food is simple — a mound of rice, yellow dal, a piece of flatbread folded on the side. "
"The mat has fraying edges and a cigarette burn mark. Against the wall behind them: a "
"stacked row of steel plates, a plastic bag hanging on a nail, peeling turquoise paint "
"revealing brown plaster underneath. Hard afternoon light from a window on the right. "
"Another child sits further back, eating, almost lost in the dim background. A power "
"cable runs along the top of the wall. Muted, slightly desaturated, dusty color. "
"Fine grain throughout. This is a REAL moment, not posed."
),
}
def generate_and_save(filename, prompt, max_retries=3):
for attempt in range(max_retries):
try:
t0 = time.time()
print(f" [{attempt+1}/{max_retries}] {filename}...")
resp = client.models.generate_content(
model=MODEL,
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE", "TEXT"],
temperature=1.0,
),
)
for part in resp.candidates[0].content.parts:
if part.inline_data and part.inline_data.mime_type.startswith("image/"):
img = Image.open(io.BytesIO(part.inline_data.data))
if img.mode != "RGB":
img = img.convert("RGB")
path = os.path.join(OUT, filename)
# Compress to stay under 2 MB
for q in [88, 82, 75, 65]:
img.save(path, "JPEG", quality=q, optimize=True, progressive=True)
if os.path.getsize(path) < 2_000_000:
break
sz = os.path.getsize(path)
print(f" [OK] {filename} -- {img.width}x{img.height}, {sz//1024} KB, {time.time()-t0:.1f}s")
return True
print(f" [WARN] no image in response")
except Exception as e:
print(f" [ERR] {str(e)[:200]}")
if attempt < max_retries - 1:
time.sleep(5 * (attempt + 1))
print(f" [FAIL] {filename}")
return False
if __name__ == "__main__":
print(f"Generating {len(PROMPTS)} kaffarah replacements -> {os.path.abspath(OUT)}\n")
ok = 0
for fn, prompt in PROMPTS.items():
if generate_and_save(fn, prompt):
ok += 1
time.sleep(2)
print(f"\nDone: {ok}/{len(PROMPTS)}")
for f in sorted(os.listdir(OUT)):
sz = os.path.getsize(os.path.join(OUT, f))
print(f" {f} -- {sz//1024} KB")

84
gen_personas.py Normal file
View File

@@ -0,0 +1,84 @@
"""Generate 4 persona images for the landing page gap-px grid.
All landscape 3:2, documentary candid, consistent warm tone.
Uses gemini-3-pro-image-preview (Nano Banana Pro).
"""
import os, time, sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from google import genai
from google.genai import types
client = genai.Client(api_key="AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E")
OUT = "pledge-now-pay-later/public/images/landing"
PROMPTS = {
"persona-charity-manager.jpg": (
"A British South Asian woman in her late 40s wearing a navy cardigan and simple hijab, "
"sitting at a desk in a mosque community room. She is looking down at a laptop screen, focused, "
"one hand on the trackpad. Warm tungsten overhead lights. A prayer timetable is pinned to a "
"corkboard on the wall behind her. Stacks of folders and a mug of tea on the desk. "
"Shot on Canon EOS R5, 50mm, f/2.0, available light. Documentary candid, not looking at camera. "
"Warm, grounded, purposeful. 3:2 landscape aspect ratio."
),
"persona-programme-manager.jpg": (
"A British Arab man in his mid-30s wearing a smart navy polo shirt, sitting alone at a desk "
"in a modern charity office. He has a laptop open and a printed spreadsheet with highlighted rows "
"beside it. He is writing notes in a Moleskine notebook, pen in hand, concentrating. "
"Natural window light from the left, soft shadows. A monitor showing a campaign calendar is "
"blurred in the background. Clean desk, professional but not corporate. "
"Shot on Canon EOS R5, 35mm, f/1.8, available light. Documentary candid. "
"Organised, calm authority. 3:2 landscape aspect ratio."
),
"persona-fundraiser.jpg": (
"A young British Black woman in her mid-20s sitting on a bench in a London park, looking at her "
"phone screen. She wears a casual olive utility jacket and has a tote bag beside her. "
"Overcast British daylight, soft diffused light. Shallow depth of field — bare winter trees and "
"a path blurred behind her. She looks focused and slightly pleased at something on the screen. "
"Shot on Sony A7III, 85mm, f/1.4, natural light. Documentary street photography. "
"Independent, resourceful. 3:2 landscape aspect ratio."
),
"persona-volunteer.jpg": (
"A young British South Asian man in his early 20s wearing a lanyard and a plain dark polo shirt, "
"leaning forward at a round table during a charity dinner gala. He is handing a small card "
"to a seated older woman across the table. Warm gala tungsten lighting, white tablecloths, "
"bokeh chandeliers in the background. Other guests are blurred but visible at adjacent tables. "
"Shot on Canon EOS R5, 50mm, f/1.8, available light. Documentary candid event photography. "
"Energetic, helpful. 3:2 landscape aspect ratio."
),
}
def generate(filename, prompt):
t0 = time.time()
print(f" [GEN] {filename}...")
try:
resp = client.models.generate_content(
model="gemini-3-pro-image-preview",
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
),
)
for part in resp.candidates[0].content.parts:
if part.inline_data:
path = os.path.join(OUT, filename)
with open(path, "wb") as f:
f.write(part.inline_data.data)
sz = os.path.getsize(path) / 1024
print(f" [OK] {filename} -- {sz:.0f}KB ({time.time()-t0:.1f}s)")
return filename, True
print(f" [FAIL] {filename} -- no image in response")
return filename, False
except Exception as e:
print(f" [FAIL] {filename} -- {e}")
return filename, False
if __name__ == "__main__":
print(f"Generating {len(PROMPTS)} persona images...")
ok, fail = 0, 0
# Generate 2 at a time (rate limits)
with ThreadPoolExecutor(max_workers=2) as pool:
futures = {pool.submit(generate, fn, p): fn for fn, p in PROMPTS.items()}
for f in as_completed(futures):
_, success = f.result()
if success: ok += 1
else: fail += 1
print(f"\nDone: {ok} ok, {fail} failed")

84
gen_personas_v2.py Normal file
View File

@@ -0,0 +1,84 @@
"""Generate 4 cinematic persona images — moment shots, not portraits.
Wide 2:1 aspect ratio for editorial strip layout.
"""
import os, time
from concurrent.futures import ThreadPoolExecutor, as_completed
from google import genai
from google.genai import types
client = genai.Client(api_key="AIzaSyCHnesXLjPw-UgeZaQotut66bgjFdvy12E")
OUT = "pledge-now-pay-later/public/images/landing"
PROMPTS = {
"persona-01-dinner.jpg": (
"End of a charity fundraising dinner. A round banquet table, white tablecloth slightly rumpled. "
"A single small pledge card sits slightly askew on the tablecloth near an empty water glass. "
"Chairs pushed back. Warm tungsten chandelier light creates golden glow. A few guests leaving "
"in the far background, blurred. Crumpled napkin. The generosity happened here, but the table "
"is emptying. Cinematic wide shot, melancholy but beautiful. No one looking at camera. "
"Shot on Leica Q2, 28mm, f/1.7, available light. Portra-like color rendering. "
"2:1 landscape aspect ratio."
),
"persona-02-phone.jpg": (
"Close-up of a British South Asian woman's hands holding a smartphone above a wooden kitchen table. "
"The phone screen shows a messaging app with a green chat bubble. Warm afternoon window light "
"from the left illuminates the scene. Shallow depth of field - the rest of the table (a mug, "
"a notebook) is softly blurred. Her sleeves are pushed up casually. Intimate, everyday, relatable. "
"Not looking at camera - we only see hands and phone. "
"Shot on Sony A7III, 55mm, f/1.4, natural light. Warm, honest. "
"2:1 landscape aspect ratio."
),
"persona-03-volunteer.jpg": (
"A young British South Asian man with a lanyard and dark polo shirt, seen from behind and slightly "
"to the side, walking between round dinner tables at a charity gala. He carries a small stack of "
"cards in one hand. Seated guests in smart dress at the white-clothed tables. Warm golden gala "
"lighting, crystal chandelier bokeh in the upper background. The volunteer is in motion, purposeful. "
"Not looking at camera. Other volunteers visible but blurred. Energy, purpose, youth. "
"Shot on Canon EOS R5, 35mm, f/1.8, available light. Documentary candid event photography. "
"2:1 landscape aspect ratio."
),
"persona-04-desk.jpg": (
"A clean wooden desk photographed from a 45-degree overhead angle. An open laptop shows a "
"spreadsheet with organized rows and a green progress indicator. Beside the laptop: a neat stack "
"of printed A4 papers with a black binder clip, a fine-point pen, and a ceramic mug of tea. "
"Soft morning window light from the left creates gentle shadows. No person visible - just the "
"workspace. Everything is orderly, calm, under control. Documentary still life. "
"Shot on Fujifilm GFX 50S, 63mm, f/4.0, natural light. Calm, precise. "
"2:1 landscape aspect ratio."
),
}
def generate(filename, prompt):
t0 = time.time()
print(f" [GEN] {filename}...")
try:
resp = client.models.generate_content(
model="gemini-3-pro-image-preview",
contents=prompt,
config=types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
),
)
for part in resp.candidates[0].content.parts:
if part.inline_data:
path = os.path.join(OUT, filename)
with open(path, "wb") as f:
f.write(part.inline_data.data)
sz = os.path.getsize(path) / 1024
print(f" [OK] {filename} -- {sz:.0f}KB ({time.time()-t0:.1f}s)")
return filename, True
print(f" [FAIL] {filename} -- no image in response")
return filename, False
except Exception as e:
print(f" [FAIL] {filename} -- {e}")
return filename, False
if __name__ == "__main__":
print(f"Generating {len(PROMPTS)} cinematic persona images...")
ok = 0
with ThreadPoolExecutor(max_workers=2) as pool:
futures = {pool.submit(generate, fn, p): fn for fn, p in PROMPTS.items()}
for f in as_completed(futures):
_, success = f.result()
if success: ok += 1
print(f"\nDone: {ok}/{len(PROMPTS)} ok")

229
job-application-guide.md Normal file
View File

@@ -0,0 +1,229 @@
# 🎯 Killer Application Guide — Full-Stack Engineer @ Calvana LTD
---
## 📋 JOB REVIEW SUMMARY
### Company: Calvana LTD
- **B2B SaaS startup** solving client acquisition for B2B companies
- Starting by dominating the "internet marketing" agency & coaching market
- Claims: cash ✅, audience ✅, distribution ✅, product-market fit ✅
- Looking for **first engineering hires** — massive ownership opportunity
- Has a Loom video: https://www.loom.com/share/1e6f7f6255d74e7785a7a8e48c2d5788
- $2,000 referral bonus signals they're actively hunting
### Role: Full-Stack Engineer (Early Hire)
- **Location:** Remote (ideally London timezone)
- **Type:** Full-time
- **Stack:** Next.js (frontend) + Django/PostgreSQL (backend) + Pulumi/AWS (infra)
- **Nature:** End-to-end ownership, microservices, AI-powered features, 3rd-party API integrations
### 🔑 What They REALLY Want (Reading Between the Lines)
1. **A builder, not an employee** — someone who acts like a co-founder
2. **Self-directed** — no PM, no Figma specs, no hand-holding
3. **Speed over perfection** — ship fast, iterate, "high velocity"
4. **AI-native** — not just curious, but has actually BUILT with AI APIs
5. **Full ownership** — from idea → architecture → code → deploy → monitor
6. **Communication** — small team, you explain your own decisions
### ⚠️ Red/Yellow Flags to Be Aware Of
- "Multi-billion dollar vision" is ambitious language — be prepared for startup chaos
- "No AI screening" = the founder (Charlie) reads every app personally → **personalize everything**
- Early hire = wear many hats, likely no work-life balance initially
---
## 📝 APPLICATION FORM — FIELD-BY-FIELD STRATEGY
The Google Form has **14 fields**. Here's how to make each one count:
---
### 1. Full Name *(required)*
> Just your name. No tricks here.
### 2. Email Address *(required)*
> Use a professional email. If you have a custom domain, use it — it signals you're technical.
### 3. LinkedIn / Personal Site / Portfolio *(required)*
> **Priority order:** Personal site > LinkedIn > Portfolio
> If you have a personal site with projects, that's gold. It shows you ship.
> Make sure your LinkedIn headline matches what they want: "Full-Stack Engineer | Next.js + Django | Building AI-powered products"
### 4. GitHub or Equivalent *(required)*
> **Make sure your pinned repos showcase:**
> - A full-stack project (React/Next.js + Python backend)
> - Something with AI/ML APIs
> - Clean READMEs with screenshots, architecture diagrams
> - Recent commit activity (shows you're active)
### 5. Location *(required)*
> Be honest. If you're not in London, emphasize timezone overlap willingness.
> Example: "Manila, Philippines (happy to work London hours / significant overlap)"
### 6. Employment Status *(required, radio)*
> Options: Employed full-time | Employed part-time | Between roles | Freelancing | Running my own thing
> **"Running my own thing" or "Freelancing"** are the strongest signals for this role — it shows self-direction.
> "Employed full-time" is fine too — shows you're in demand.
---
### 7. 🔥 CRITICAL: "Describe something you built end-to-end" *(required)*
**This is the MAKE-OR-BREAK question.** They explicitly want: problem → decisions → deployment.
**Structure your answer like this (aim for 200-350 words):**
```
PROBLEM: [1-2 sentences — what pain point existed]
WHAT I BUILT: [What the product/feature was, who it served]
KEY DECISIONS:
- Chose [X] over [Y] because [reason] → shows architectural thinking
- Used [specific tech] for [specific reason] → shows you don't just follow tutorials
- Handled [edge case/challenge] by [solution] → shows production mindset
RESULT: [Quantifiable if possible — users, performance, revenue, time saved]
SHIPPED TO: [Where it's live — URL, app store, internal tool]
```
**EXAMPLE (adapt to your experience):**
> I noticed freelancers in my network were losing 5-10 hours/week manually creating client proposals. I built ProposalPilot — an AI-powered proposal generator.
>
> Frontend: Next.js with TailwindCSS, deployed on Vercel. Backend: Django REST API on AWS ECS with PostgreSQL. The AI pipeline used OpenAI's API for content generation and a custom prompt chaining system I built to maintain brand voice consistency across sections.
>
> Key decisions: I chose Django over Express because I needed robust ORM support for complex relational data (clients, templates, proposal versions). I containerized each service with Docker and used GitHub Actions for CI/CD. For the AI layer, I implemented streaming responses so users see content generating in real-time rather than waiting 15-20 seconds for a full response.
>
> The hardest part was handling rate limits and failures from OpenAI gracefully — I built a retry queue with exponential backoff and a fallback template system so proposals never fail completely.
>
> Result: 40+ active users, avg. proposal creation time dropped from 3 hours to 20 minutes. The project is live at [URL].
---
### 8. Link to Something You've Built *(optional but DO IT)*
> This is your proof. Link to:
> - A live product URL (best)
> - A GitHub repo with a stellar README + demo GIF
> - A Loom walkthrough of your project
> - A technical blog post about the build
### 9. 🔥 AI/ML API Experience *(optional but CRITICAL for this role)*
**They specifically mention: OpenAI, ElevenLabs, Replicate, Whisper, Stable Diffusion**
**Structure:**
```
WHAT I BUILT: [Specific project using AI APIs]
APIS USED: [List them — the more the better]
WHAT I LEARNED: [Focus on production challenges, not just "I called the API"]
```
**EXAMPLE:**
> I built an AI voice-over tool for content creators using ElevenLabs for TTS, OpenAI for script optimization, and Whisper for transcription/captioning. The pipeline: user uploads a script → GPT-4 optimizes it for spoken delivery → ElevenLabs generates audio with voice cloning → Whisper generates timestamped subtitles.
>
> Key learnings: ElevenLabs' streaming API is great for previews but you need the non-streaming endpoint for production-quality audio. I learned to manage API costs by implementing a caching layer — identical scripts don't regenerate audio. Also built a webhook system since audio generation is async and can take 10-30 seconds for long content.
>
> The biggest insight was that prompt engineering for TTS scripts is fundamentally different from chat — you need to engineer for prosody, pacing, and emphasis, not just content accuracy.
---
### 10. Tech Skills Grid *(required)*
Rate honestly — they'll verify in interviews. Here's the scale:
| Tech | never used | used once | decent | strong | production-level |
|------|-----------|-----------|--------|--------|-----------------|
| React / Next.js | | | | ← aim here | ← or here |
| Python / Django | | | | ← aim here | ← or here |
| PostgreSQL | | | | ← aim here | ← or here |
| AWS | | | ← minimum | ← ideal | |
| REST API design | | | | | ← aim here |
| OAuth | | | ← minimum | ← ideal | |
| CI/CD | | | ← minimum | ← ideal | |
| Docker | | | ← minimum | ← ideal | |
**Don't lie.** "Decent experience" with honesty beats "production-level" that crumbles in an interview.
---
### 11. 🔥 "Why does this role interest you?" *(required)*
**DO NOT write generic "I love startups" garbage.** They read every application personally.
**Formula: Mirror their language + show you understand the stage + add a personal hook**
**EXAMPLE:**
> Three things stood out:
>
> First, the ownership. I've worked in teams where I owned a component, not a problem. You're describing the opposite — pick up a problem space, scope it, build it, ship it. That's exactly how I work best. My best projects happened when nobody told me what to build.
>
> Second, the timing. Being an early engineering hire at a company with existing revenue and PMF is the sweet spot. You've de-risked the "will anyone pay for this?" question, and now it's about building fast enough to capture the market. That's where I thrive.
>
> Third, the stack and the AI angle. I've been building with Next.js and Django professionally, and I've been deep in the AI API ecosystem for the past year. The idea of owning AI-powered features end-to-end at a company that's actually shipping (not just experimenting) is exactly where I want to be.
>
> I watched the Loom — Charlie's energy and clarity about the vision is compelling. I want to be part of building this.
**(Note: mentioning the Loom video by name shows you actually watched it — huge signal)**
---
### 12. Salary Expectation *(required)*
> Research tips:
> - Remote full-stack roles in London-adjacent timezone: £50k-£80k+ for early hires
> - If you're outside UK, adjust for cost-of-living but don't lowball yourself
> - Frame it: "$XX,000 USD / year — open to discussion based on equity/benefits package"
> - Showing flexibility on comp structure (salary + equity) signals founder-mindset
### 13. How Soon Could You Start? *(required)*
> **"Immediately" or "< 2 weeks"** are strongest signals for an early-stage startup that needs to move fast.
> If you need to give notice, "< 1 month" is still fine.
### 14. Loom Video *(optional — but THIS is your secret weapon)*
**This is how you separate yourself from 95% of applicants.**
**Record a 2-minute Loom with this structure:**
- **0:00-0:15** — "Hi Charlie, I'm [name], [one-line positioning]"
- **0:15-0:45** — Quick walkthrough of something you built (screen share a project)
- **0:45-1:30** — Why THIS role specifically (mirror their language: ownership, velocity, AI)
- **1:30-2:00** — "Here's what I'd build first if I joined" (show you've thought about their product)
**Tips:**
- Use their founder's name (Charlie — from the Loom video)
- Show energy and enthusiasm — match their "going to the moon" vibe
- Share your screen showing a real project, not just a talking head
- Keep it under 2 minutes — respect their time
---
## 🏆 APPLICATION CHECKLIST
Before you submit, verify:
- [ ] GitHub pinned repos are updated with best projects + clean READMEs
- [ ] LinkedIn headline/summary reflects full-stack + AI capabilities
- [ ] "Built end-to-end" answer follows Problem → Decisions → Result structure
- [ ] AI/ML answer shows PRODUCTION challenges, not just tutorial-level usage
- [ ] "Why this role" mentions specifics from THEIR posting (Loom, microservices, PMF)
- [ ] Salary research is done — give a confident range
- [ ] Loom video recorded (2 min, high energy, shows a real project)
- [ ] All required fields filled (13 required, 1 optional)
- [ ] Re-read everything — no typos, no generic language
---
## 💡 POWER MOVES (Stand Out Tactics)
1. **Build a mini demo** — Before applying, spend 2-4 hours building a tiny microservice that solves a problem relevant to their space (e.g., an AI-powered lead qualifier). Link it in your "built something" answer. Nothing says "I ship" like shipping something FOR them.
2. **Reference the Loom** — The founder recorded a 7-minute Loom. Most applicants won't watch it. Reference specific things from it to prove you did.
3. **Show, don't tell** — Every claim should have a link, a repo, or a demo. "I've built with AI APIs" < "Here's the repo where I integrated OpenAI + ElevenLabs: [link]"
4. **Think like a founder** — In your "why this role" answer, mention what you'd want to build first. Shows you're already thinking about their product, not just your career.
5. **Follow up** — If you can find Charlie on LinkedIn/Twitter, send a short "Just applied — excited about [specific thing]" message 24h after applying.

BIN
job-page-top.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

1
justvitamin-build Submodule

Submodule justvitamin-build added at 21f67d39c7

BIN
jv-before-pdp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

1
jv_api_data.json Normal file

File diff suppressed because one or more lines are too long

198
lib/agent-worker.ts Normal file
View File

@@ -0,0 +1,198 @@
import Anthropic from "@anthropic-ai/sdk";
import { toolDefs, executeTool } from "./tools.js";
import * as store from "./store.js";
import * as tg from "./telegram.js";
const client = new Anthropic();
const MODEL = "claude-sonnet-4-20250514";
const MAX_TURNS = 50;
// Map of agentId -> resolve function for when user replies
const waitingForUser = new Map<number, (response: string) => void>();
export function isWaitingForUser(agentId: number): boolean {
return waitingForUser.has(agentId);
}
export function resolveUserResponse(agentId: number, response: string): void {
const resolve = waitingForUser.get(agentId);
if (resolve) {
waitingForUser.delete(agentId);
resolve(response);
}
}
function buildSystemPrompt(task: string): string {
return `You are an autonomous coding agent. You have been assigned a specific task.
YOUR TASK:
${task}
GUIDELINES:
- Work independently to complete the task
- Use the bash tool for running commands, git, etc.
- Use read_file, write_file, edit_file for file operations
- When you're done, call the "done" tool with a summary
- If you need user input or a decision, call "ask_user"
- Be efficient — don't explain what you're about to do, just do it
- If something fails, try to fix it yourself before asking the user
WORKING DIRECTORY: ${process.cwd()}`;
}
export async function runAgent(agentId: number): Promise<void> {
const agent = store.getAgent(agentId);
if (!agent) return;
store.updateAgent(agentId, { status: "working" });
store.addLog(agentId, "system", `Agent started: ${agent.task}`);
const messages: Anthropic.MessageParam[] = [
{ role: "user", content: agent.task },
];
let turns = 0;
try {
while (turns < MAX_TURNS) {
turns++;
const response = await client.messages.create({
model: MODEL,
max_tokens: 8096,
system: buildSystemPrompt(agent.task),
tools: toolDefs as any,
messages,
});
// Collect assistant content
const assistantContent = response.content;
messages.push({ role: "assistant", content: assistantContent });
// Log text blocks (no Telegram notification — reduces noise)
for (const block of assistantContent) {
if (block.type === "text" && block.text.trim()) {
store.addLog(agentId, "assistant", block.text);
}
}
// If no tool use, we're done
if (response.stop_reason !== "tool_use") {
store.updateAgent(agentId, {
status: "done",
summary: "Completed (no more actions)",
});
store.addLog(agentId, "system", "Agent finished (end_turn)");
await tg.send(
`✅ *Agent #${agentId}* finished.\nTask: ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
return;
}
// Process tool calls
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const block of assistantContent) {
if (block.type !== "tool_use") continue;
const toolName = block.name;
const toolInput = block.input as Record<string, unknown>;
store.addLog(
agentId,
"tool",
`${toolName}: ${JSON.stringify(toolInput).slice(0, 500)}`
);
if (toolName === "ask_user") {
// Pause and wait for user response
store.updateAgent(agentId, { status: "waiting" });
await tg.send(
`❓ *Agent #${agentId}* needs your input:\n\n${toolInput.question}`,
agent.chat_id,
{
reply_to: agent.thread_msg_id || undefined,
keyboard: [
[{ text: "💬 Reply", callback_data: `talk_${agentId}` }],
],
}
);
store.addLog(agentId, "system", `Waiting for user: ${toolInput.question}`);
// Wait for user response
const userResponse = await new Promise<string>((resolve) => {
waitingForUser.set(agentId, resolve);
});
store.updateAgent(agentId, { status: "working" });
store.addLog(agentId, "user", `User replied: ${userResponse}`);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: `User responded: ${userResponse}`,
});
continue;
}
if (toolName === "done") {
const summary = (toolInput.summary as string) || "Task completed";
store.updateAgent(agentId, { status: "done", summary });
store.addLog(agentId, "system", `Done: ${summary}`);
await tg.send(
`✅ *Agent #${agentId}* completed!\n\n*Summary:* ${summary}\n*Task:* ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: summary,
});
// Push tool results and stop
messages.push({ role: "user", content: toolResults });
return;
}
// Execute the tool
const { result } = executeTool(toolName, toolInput);
store.addLog(
agentId,
"tool_result",
`${toolName}${result.slice(0, 500)}`
);
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result,
});
}
messages.push({ role: "user", content: toolResults });
}
// Hit max turns
store.updateAgent(agentId, {
status: "done",
summary: `Stopped after ${MAX_TURNS} turns`,
});
await tg.send(
`⚠️ *Agent #${agentId}* hit max turns (${MAX_TURNS}). Task: ${agent.task}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
} catch (e: any) {
console.error(`[agent ${agentId}] error:`, e);
store.updateAgent(agentId, { status: "error", error: e.message });
store.addLog(agentId, "error", e.message);
await tg.send(
`❌ *Agent #${agentId}* error:\n${e.message?.slice(0, 500)}`,
agent.chat_id,
{ reply_to: agent.thread_msg_id || undefined }
);
}
}

341
lib/bot.ts Normal file
View File

@@ -0,0 +1,341 @@
import * as tg from "./telegram.js";
import * as store from "./store.js";
import { runAgent, isWaitingForUser, resolveUserResponse } from "./agent-worker.js";
// Track which agents are running (in-process)
const runningAgents = new Set<number>();
// Track "talk mode" — chatId -> agentId they're talking to
const talkMode = new Map<number, number>();
const STATUS_EMOJI: Record<string, string> = {
spawning: "🔄",
working: "⚡",
waiting: "❓",
done: "✅",
error: "❌",
killed: "🛑",
};
async function handleMessage(msg: NonNullable<tg.TelegramUpdate["message"]>) {
const chatId = msg.chat.id;
const text = (msg.text || "").trim();
if (!tg.isAllowed(chatId)) return;
// Check if user is in talk mode with an agent
if (talkMode.has(chatId) && !text.startsWith("/")) {
const agentId = talkMode.get(chatId)!;
talkMode.delete(chatId);
if (isWaitingForUser(agentId)) {
resolveUserResponse(agentId, text);
await tg.send(`💬 Sent to Agent #${agentId}`, chatId);
} else {
// Agent is working but user wants to interject — add as follow-up
// For now just queue it
store.addLog(agentId, "user", text);
await tg.send(
`📝 Noted for Agent #${agentId}. It's currently working — your message will be seen when it next checks.`,
chatId
);
}
return;
}
// Check if this is a reply to an agent thread
if (msg.reply_to_message && !text.startsWith("/")) {
const replyToId = msg.reply_to_message.message_id;
const agent = store.findAgentByThreadMsg(replyToId);
if (agent && isWaitingForUser(agent.id)) {
resolveUserResponse(agent.id, text);
await tg.send(`💬 Sent to Agent #${agent.id}`, chatId);
return;
}
// Also check all agents for this chat — find closest thread
const agents = store.listAgents(String(chatId));
for (const a of agents) {
if (a.thread_msg_id === replyToId && isWaitingForUser(a.id)) {
resolveUserResponse(a.id, text);
await tg.send(`💬 Sent to Agent #${a.id}`, chatId);
return;
}
}
}
// Commands
if (text.startsWith("/new ") || text.startsWith("/new@")) {
const task = text.replace(/^\/new(@\w+)?\s*/, "").trim();
if (!task) {
await tg.send("Usage: `/new <task description>`", chatId);
return;
}
await spawnAgent(chatId, task);
return;
}
if (text === "/board" || text.startsWith("/board@")) {
await showBoard(chatId);
return;
}
if (text.startsWith("/kill ") || text.startsWith("/kill@")) {
const idStr = text.replace(/^\/kill(@\w+)?\s*/, "").trim();
const id = parseInt(idStr);
if (isNaN(id)) {
await tg.send("Usage: `/kill <agent_id>`", chatId);
return;
}
await killAgent(chatId, id);
return;
}
if (text.startsWith("/logs ") || text.startsWith("/logs@")) {
const idStr = text.replace(/^\/logs(@\w+)?\s*/, "").trim();
const id = parseInt(idStr);
if (isNaN(id)) {
await tg.send("Usage: `/logs <agent_id>`", chatId);
return;
}
await showLogs(chatId, id);
return;
}
if (text.startsWith("/talk ") || text.startsWith("/talk@")) {
const idStr = text.replace(/^\/talk(@\w+)?\s*/, "").trim();
const id = parseInt(idStr);
if (isNaN(id)) {
await tg.send("Usage: `/talk <agent_id>`", chatId);
return;
}
talkMode.set(chatId, id);
await tg.send(
`💬 You're now talking to *Agent #${id}*. Send your message (or /cancel):`,
chatId
);
return;
}
if (text === "/cancel" || text.startsWith("/cancel@")) {
if (talkMode.has(chatId)) {
talkMode.delete(chatId);
await tg.send("Cancelled talk mode.", chatId);
}
return;
}
if (text === "/clear" || text.startsWith("/clear@")) {
await clearChat(chatId, msg.message_id);
return;
}
if (text === "/help" || text === "/start" || text.startsWith("/help@") || text.startsWith("/start@")) {
await tg.send(
`🤖 *Agent Orchestrator*
Commands:
/new <task> — Spawn a new agent
/board — View all agents
/logs <id> — Agent activity log
/talk <id> — Send message to agent
/kill <id> — Stop an agent
/clear — Clear chat messages
/help — This message
Or *reply* to any agent message to talk to it directly.`,
chatId
);
return;
}
// Unknown
if (text.startsWith("/")) {
await tg.send("Unknown command. Try /help", chatId);
}
}
async function handleCallback(cb: NonNullable<tg.TelegramUpdate["callback_query"]>) {
const chatId = cb.message?.chat?.id;
if (!chatId || !tg.isAllowed(chatId)) return;
const data = cb.data || "";
await tg.answerCallback(cb.id);
if (data.startsWith("logs_")) {
const id = parseInt(data.replace("logs_", ""));
await showLogs(chatId, id);
} else if (data.startsWith("talk_")) {
const id = parseInt(data.replace("talk_", ""));
talkMode.set(chatId, id);
await tg.send(
`💬 Talking to *Agent #${id}*. Send your message:`,
chatId
);
} else if (data.startsWith("kill_")) {
const id = parseInt(data.replace("kill_", ""));
await killAgent(chatId, id);
}
}
async function spawnAgent(chatId: number, task: string) {
const agent = store.createAgent(task, String(chatId));
// Send initial message and save its ID for threading
const res = await tg.send(
`🤖 *Agent #${agent.id}* spawned\n*Task:* ${task}\n*Status:* spawning...`,
chatId,
{ keyboard: tg.agentKeyboard(agent.id) }
);
if (res?.result?.message_id) {
store.updateAgent(agent.id, { thread_msg_id: res.result.message_id });
}
// Run agent in background (non-blocking)
runningAgents.add(agent.id);
runAgent(agent.id)
.catch((e) => console.error(`[agent ${agent.id}] fatal:`, e))
.finally(() => runningAgents.delete(agent.id));
}
async function showBoard(chatId: number) {
const agents = store.listAgents(String(chatId));
if (agents.length === 0) {
await tg.send("No agents yet. Use `/new <task>` to spawn one.", chatId);
return;
}
let board = "📋 *Agent Board*\n\n";
board += "```\n";
board += " # | Status | Task\n";
board += "----|--------|---------------------------\n";
for (const a of agents.slice(0, 20)) {
const emoji = STATUS_EMOJI[a.status] || "❔";
const taskShort = a.task.length > 30 ? a.task.slice(0, 27) + "..." : a.task;
board += ` ${String(a.id).padStart(2)} | ${emoji} ${a.status.padEnd(4).slice(0, 4)} | ${taskShort}\n`;
}
board += "```\n";
// Show summaries for done agents
const done = agents.filter((a) => a.summary);
if (done.length > 0) {
board += "\n*Completed:*\n";
for (const a of done.slice(0, 5)) {
board += `• #${a.id}: ${a.summary}\n`;
}
}
await tg.send(board, chatId);
}
async function killAgent(chatId: number, agentId: number) {
const agent = store.getAgent(agentId);
if (!agent) {
await tg.send(`Agent #${agentId} not found.`, chatId);
return;
}
if (agent.chat_id !== String(chatId)) {
await tg.send(`Agent #${agentId} doesn't belong to you.`, chatId);
return;
}
if (agent.status === "done" || agent.status === "killed") {
await tg.send(`Agent #${agentId} is already ${agent.status}.`, chatId);
return;
}
store.updateAgent(agentId, { status: "killed", summary: "Killed by user" });
store.addLog(agentId, "system", "Killed by user");
// If waiting for user, resolve with cancellation
if (isWaitingForUser(agentId)) {
resolveUserResponse(agentId, "[USER CANCELLED THIS AGENT]");
}
await tg.send(`🛑 Agent #${agentId} killed.`, chatId);
}
async function showLogs(chatId: number, agentId: number) {
const agent = store.getAgent(agentId);
if (!agent) {
await tg.send(`Agent #${agentId} not found.`, chatId);
return;
}
const logs = store.getLogs(agentId, 15);
if (logs.length === 0) {
await tg.send(`No logs for Agent #${agentId}.`, chatId);
return;
}
let text = `📋 *Logs — Agent #${agentId}*\n_${agent.task}_\n\n`;
for (const log of logs.reverse()) {
const role = log.role.toUpperCase().padEnd(6).slice(0, 6);
const content = log.content.length > 150 ? log.content.slice(0, 147) + "..." : log.content;
text += `\`${role}\` ${content}\n\n`;
}
await tg.send(text, chatId, { keyboard: tg.agentKeyboard(agentId) });
}
async function clearChat(chatId: number, commandMsgId: number) {
// Delete the /clear command message itself first
await tg.deleteMessage(chatId, commandMsgId);
// Telegram only allows deleting messages less than 48h old.
// We walk backwards from the command message ID, trying to delete recent messages.
const statusMsg = await tg.send("🧹 Clearing chat...", chatId);
const statusMsgId: number | undefined = statusMsg?.result?.message_id;
let deleted = 0;
let misses = 0;
const MAX_MISSES = 10; // stop after 10 consecutive failures (hit old messages or gap)
// Walk backwards from the /clear message
for (let id = commandMsgId - 1; id > 0 && misses < MAX_MISSES; id--) {
const ok = await tg.deleteMessage(chatId, id);
if (ok) {
deleted++;
misses = 0;
} else {
misses++;
}
}
// Delete the status message too, then send a clean confirmation
if (statusMsgId) await tg.deleteMessage(chatId, statusMsgId);
await tg.send(`🧹 Cleared ${deleted} messages.`, chatId);
}
// --- Main loop ---
async function main() {
console.log("🤖 Telegram Agent Orchestrator starting...");
console.log(` Polling for updates...`);
await tg.send("🤖 Agent Orchestrator is online! Send /help to get started.");
while (true) {
try {
const updates = await tg.poll();
for (const update of updates) {
if (update.message) {
handleMessage(update.message).catch((e) =>
console.error("[handle msg]", e)
);
}
if (update.callback_query) {
handleCallback(update.callback_query).catch((e) =>
console.error("[handle cb]", e)
);
}
}
} catch (e) {
console.error("[main loop]", e);
await new Promise((r) => setTimeout(r, 5000));
}
}
}
main();

179
lib/store.ts Normal file
View File

@@ -0,0 +1,179 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
mkdirSync("data", { recursive: true });
const db = new Database("data/agents.db");
db.run("PRAGMA journal_mode = WAL");
db.run(`
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'spawning',
chat_id TEXT NOT NULL,
thread_msg_id INTEGER,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
summary TEXT,
error TEXT
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS agent_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (agent_id) REFERENCES agents(id)
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
tool_use TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (agent_id) REFERENCES agents(id)
)
`);
export interface Agent {
id: number;
task: string;
status: string;
chat_id: string;
thread_msg_id: number | null;
created_at: string;
updated_at: string;
summary: string | null;
error: string | null;
}
export interface AgentLog {
id: number;
agent_id: number;
role: string;
content: string;
created_at: string;
}
// --- Agent CRUD ---
export function createAgent(task: string, chatId: string): Agent {
const stmt = db.prepare(
`INSERT INTO agents (task, status, chat_id) VALUES (?, 'spawning', ?)`
);
stmt.run(task, chatId);
const id = Number(db.query("SELECT last_insert_rowid() as id").get()!.id);
return getAgent(id)!;
}
export function getAgent(id: number): Agent | undefined {
return db.query(`SELECT * FROM agents WHERE id = ?`).get(id) as
| Agent
| undefined;
}
export function updateAgent(
id: number,
updates: Partial<Pick<Agent, "status" | "thread_msg_id" | "summary" | "error">>
): void {
const fields: string[] = ["updated_at = datetime('now')"];
const values: unknown[] = [];
if (updates.status !== undefined) {
fields.push("status = ?");
values.push(updates.status);
}
if (updates.thread_msg_id !== undefined) {
fields.push("thread_msg_id = ?");
values.push(updates.thread_msg_id);
}
if (updates.summary !== undefined) {
fields.push("summary = ?");
values.push(updates.summary);
}
if (updates.error !== undefined) {
fields.push("error = ?");
values.push(updates.error);
}
values.push(id);
db.prepare(`UPDATE agents SET ${fields.join(", ")} WHERE id = ?`).run(
...values
);
}
export function listAgents(chatId?: string): Agent[] {
if (chatId) {
return db
.query(`SELECT * FROM agents WHERE chat_id = ? ORDER BY id DESC`)
.all(chatId) as Agent[];
}
return db.query(`SELECT * FROM agents ORDER BY id DESC`).all() as Agent[];
}
export function getActiveAgents(): Agent[] {
return db
.query(
`SELECT * FROM agents WHERE status IN ('spawning', 'working', 'waiting') ORDER BY id`
)
.all() as Agent[];
}
export function findAgentByThreadMsg(messageId: number): Agent | undefined {
return db
.query(`SELECT * FROM agents WHERE thread_msg_id = ?`)
.get(messageId) as Agent | undefined;
}
// --- Logs ---
export function addLog(agentId: number, role: string, content: string): void {
db.prepare(
`INSERT INTO agent_logs (agent_id, role, content) VALUES (?, ?, ?)`
).run(agentId, role, content);
}
export function getLogs(agentId: number, limit = 20): AgentLog[] {
return db
.query(
`SELECT * FROM agent_logs WHERE agent_id = ? ORDER BY id DESC LIMIT ?`
)
.all(agentId, limit) as AgentLog[];
}
// --- Messages (conversation history) ---
export function addMessage(
agentId: number,
role: string,
content: string,
toolUse?: string
): void {
db.prepare(
`INSERT INTO agent_messages (agent_id, role, content, tool_use) VALUES (?, ?, ?, ?)`
).run(agentId, role, content, toolUse || null);
}
export function getMessages(
agentId: number
): Array<{ role: string; content: string; tool_use: string | null }> {
return db
.query(
`SELECT role, content, tool_use FROM agent_messages WHERE agent_id = ? ORDER BY id ASC`
)
.all(agentId) as Array<{
role: string;
content: string;
tool_use: string | null;
}>;
}
export default db;

142
lib/telegram.ts Normal file
View File

@@ -0,0 +1,142 @@
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const DEFAULT_CHAT_ID = process.env.TELEGRAM_CHAT_ID!;
const ALLOWED_IDS = new Set(
(process.env.TELEGRAM_ALLOWED_CHAT_IDS || DEFAULT_CHAT_ID)
.split(",")
.map((id) => id.trim())
);
const api = async (method: string, body?: Record<string, unknown>) => {
const res = await fetch(
`https://api.telegram.org/bot${BOT_TOKEN}/${method}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
}
);
return res.json();
};
export function isAllowed(chatId: number | string): boolean {
return ALLOWED_IDS.has(String(chatId));
}
export async function send(
text: string,
chatId: string | number = DEFAULT_CHAT_ID,
opts?: { reply_to?: number; keyboard?: InlineKeyboard }
): Promise<any> {
if (!isAllowed(chatId)) return null;
// Telegram limits messages to 4096 chars
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
text: truncated,
parse_mode: "Markdown",
};
if (opts?.reply_to) body.reply_to_message_id = opts.reply_to;
if (opts?.keyboard) {
body.reply_markup = { inline_keyboard: opts.keyboard };
}
return api("sendMessage", body);
}
export async function editMessage(
chatId: string | number,
messageId: number,
text: string,
keyboard?: InlineKeyboard
): Promise<any> {
const truncated =
text.length > 4000 ? text.slice(0, 4000) + "\n\n... (truncated)" : text;
const body: Record<string, unknown> = {
chat_id: chatId,
message_id: messageId,
text: truncated,
parse_mode: "Markdown",
};
if (keyboard) body.reply_markup = { inline_keyboard: keyboard };
return api("editMessageText", body);
}
export async function answerCallback(
callbackId: string,
text?: string
): Promise<any> {
return api("answerCallbackQuery", {
callback_query_id: callbackId,
text,
});
}
export type InlineKeyboard = Array<
Array<{ text: string; callback_data: string }>
>;
export function agentKeyboard(agentId: number): InlineKeyboard {
return [
[
{ text: "📋 Logs", callback_data: `logs_${agentId}` },
{ text: "💬 Talk", callback_data: `talk_${agentId}` },
{ text: "🛑 Kill", callback_data: `kill_${agentId}` },
],
];
}
let offset = 0;
export interface TelegramUpdate {
update_id: number;
message?: {
message_id: number;
from?: { id: number; first_name: string };
chat: { id: number; type: string };
text?: string;
reply_to_message?: { message_id: number };
};
callback_query?: {
id: string;
from: { id: number };
message?: { message_id: number; chat: { id: number } };
data?: string;
};
}
export async function poll(): Promise<TelegramUpdate[]> {
try {
const data = await api("getUpdates", {
offset,
timeout: 30,
allowed_updates: ["message", "callback_query"],
});
const updates: TelegramUpdate[] = data.result || [];
if (updates.length > 0) {
offset = updates[updates.length - 1].update_id + 1;
}
return updates;
} catch (e) {
console.error("[telegram] poll error:", e);
return [];
}
}
export async function deleteMessage(
chatId: string | number,
messageId: number
): Promise<boolean> {
try {
const res = await api("deleteMessage", {
chat_id: chatId,
message_id: messageId,
});
return !!res?.ok;
} catch {
return false;
}
}
export default { send, editMessage, deleteMessage, poll, isAllowed, agentKeyboard, answerCallback };

194
lib/tools.ts Normal file
View File

@@ -0,0 +1,194 @@
import { execSync } from "child_process";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
import { dirname } from "path";
export interface ToolDefinition {
name: string;
description: string;
input_schema: Record<string, unknown>;
}
export const toolDefs: ToolDefinition[] = [
{
name: "bash",
description:
"Execute a bash command. Returns stdout and stderr. Use for running commands, installing packages, git, etc. Timeout: 120s.",
input_schema: {
type: "object",
properties: {
command: {
type: "string",
description: "The bash command to execute",
},
},
required: ["command"],
},
},
{
name: "read_file",
description:
"Read the contents of a file. Returns the text content. Use for examining code, configs, etc.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
limit: {
type: "number",
description: "Max lines to read (default: all)",
},
offset: {
type: "number",
description: "Line to start from, 1-indexed (default: 1)",
},
},
required: ["path"],
},
},
{
name: "write_file",
description:
"Write content to a file. Creates parent directories if needed. Overwrites existing files.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
content: {
type: "string",
description: "Content to write",
},
},
required: ["path", "content"],
},
},
{
name: "edit_file",
description:
"Edit a file by replacing exact text. oldText must match exactly including whitespace.",
input_schema: {
type: "object",
properties: {
path: { type: "string", description: "Path to the file" },
old_text: {
type: "string",
description: "Exact text to find",
},
new_text: {
type: "string",
description: "Replacement text",
},
},
required: ["path", "old_text", "new_text"],
},
},
{
name: "done",
description:
"Call this when the task is fully complete. Provide a short summary of what was accomplished.",
input_schema: {
type: "object",
properties: {
summary: {
type: "string",
description: "Short summary of what was done",
},
},
required: ["summary"],
},
},
{
name: "ask_user",
description:
"Ask the user a question when you need clarification or a decision. The user will respond via Telegram.",
input_schema: {
type: "object",
properties: {
question: {
type: "string",
description: "The question to ask",
},
},
required: ["question"],
},
},
];
export function executeTool(
name: string,
input: Record<string, unknown>
): { result: string; isDone?: boolean; isQuestion?: boolean } {
try {
switch (name) {
case "bash": {
const cmd = input.command as string;
try {
const output = execSync(cmd, {
encoding: "utf-8",
timeout: 120_000,
maxBuffer: 1024 * 1024,
cwd: process.cwd(),
});
const trimmed = output.length > 10000
? output.slice(0, 10000) + "\n...(truncated)"
: output;
return { result: trimmed || "(no output)" };
} catch (e: any) {
const stderr = e.stderr || "";
const stdout = e.stdout || "";
return {
result: `Exit code: ${e.status}\nSTDOUT: ${stdout}\nSTDERR: ${stderr}`.slice(
0,
5000
),
};
}
}
case "read_file": {
const path = input.path as string;
const content = readFileSync(path, "utf-8");
const lines = content.split("\n");
const offset = ((input.offset as number) || 1) - 1;
const limit = (input.limit as number) || lines.length;
const slice = lines.slice(offset, offset + limit).join("\n");
return {
result:
slice.length > 10000
? slice.slice(0, 10000) + "\n...(truncated)"
: slice,
};
}
case "write_file": {
const path = input.path as string;
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, input.content as string, "utf-8");
return { result: `Written ${(input.content as string).length} bytes to ${path}` };
}
case "edit_file": {
const path = input.path as string;
const content = readFileSync(path, "utf-8");
const oldText = input.old_text as string;
const newText = input.new_text as string;
if (!content.includes(oldText)) {
return { result: `ERROR: old_text not found in ${path}` };
}
writeFileSync(path, content.replace(oldText, newText), "utf-8");
return { result: `Edited ${path}` };
}
case "done": {
return { result: input.summary as string, isDone: true };
}
case "ask_user": {
return { result: input.question as string, isQuestion: true };
}
default:
return { result: `Unknown tool: ${name}` };
}
} catch (e: any) {
return { result: `Tool error: ${e.message}` };
}
}

82
optimize_images.py Normal file
View File

@@ -0,0 +1,82 @@
"""Optimize all landing images: resize + compress + strip EXIF.
Also set hero-concept-1.jpg as the new hero image."""
import sys, os, shutil
sys.stdout.reconfigure(encoding='utf-8')
from PIL import Image
IMG_DIR = "pledge-now-pay-later/public/images/landing"
BRAND_DIR = "pledge-now-pay-later/brand/photography"
# Max dimensions per image type
# Portrait (4:5 or 1:1) → max 1000px long side
# Landscape (16:9) → max 1200px wide
MAX_LANDSCAPE = 1200
MAX_PORTRAIT = 1000
QUALITY = 80
def optimize(path, max_long_side):
"""Resize + compress + strip EXIF. Returns (old_size, new_size)."""
old_size = os.path.getsize(path)
img = Image.open(path)
# Strip EXIF by creating new image
if img.mode != "RGB":
img = img.convert("RGB")
# Resize if larger than max
w, h = img.size
long_side = max(w, h)
if long_side > max_long_side:
ratio = max_long_side / long_side
new_w = int(w * ratio)
new_h = int(h * ratio)
img = img.resize((new_w, new_h), Image.LANCZOS)
# Save with progressive JPEG, quality 80, optimized
img.save(path, "JPEG", quality=QUALITY, optimize=True, progressive=True)
new_size = os.path.getsize(path)
return old_size, new_size, img.size
# Step 1: Set hero-concept-1 as the main hero
hero_src = os.path.join(IMG_DIR, "hero-concept-1.jpg")
hero_dst = os.path.join(IMG_DIR, "00-hero.jpg")
if os.path.exists(hero_src):
shutil.copy2(hero_src, hero_dst)
# Also copy to brand dir
shutil.copy2(hero_src, os.path.join(BRAND_DIR, "00-hero.jpg"))
print(f"Set hero: hero-concept-1.jpg -> 00-hero.jpg")
# Step 2: Clean up concept images
for f in ["hero-concept-1.jpg", "hero-concept-2.jpg", "hero-concept-3.jpg"]:
p = os.path.join(IMG_DIR, f)
if os.path.exists(p):
os.remove(p)
print(f"Cleaned: {f}")
# Step 3: Optimize all images
total_old = 0
total_new = 0
for fname in sorted(os.listdir(IMG_DIR)):
if not fname.endswith(".jpg"):
continue
path = os.path.join(IMG_DIR, fname)
img = Image.open(path)
w, h = img.size
img.close()
# Determine max size based on orientation
if w > h:
max_side = MAX_LANDSCAPE # landscape
else:
max_side = MAX_PORTRAIT # portrait or square
old_size, new_size, final_dims = optimize(path, max_side)
total_old += old_size
total_new += new_size
saved_pct = (1 - new_size / old_size) * 100 if old_size > 0 else 0
print(f" {fname:45s} {final_dims[0]:>5d}x{final_dims[1]:<5d} {old_size//1024:>4d}KB -> {new_size//1024:>4d}KB ({saved_pct:+.0f}%)")
print(f"\n{'='*60}")
print(f"Total: {total_old//1024}KB -> {total_new//1024}KB ({(1 - total_new/total_old)*100:.0f}% reduction)")
print(f"{'='*60}")

View File

@@ -4,9 +4,12 @@
"type": "module",
"description": "Pi Coding Agent extension playground",
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"better-sqlite3": "^12.6.2",
"yaml": "^2.8.0"
},
"devDependencies": {
"@playwright/cli": "^0.1.1"
"@playwright/cli": "^0.1.1",
"@types/better-sqlite3": "^7.6.13"
}
}

13
playwright-cli.json Normal file
View File

@@ -0,0 +1,13 @@
{
"browser": {
"browserName": "chromium",
"launchOptions": {
"headless": true,
"args": ["--ignore-certificate-errors"]
},
"contextOptions": {
"ignoreHTTPSErrors": true,
"viewport": { "width": 1440, "height": 900 }
}
}
}

View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
brand
shots

View File

@@ -41,3 +41,4 @@ next-env.d.ts
# SQLite dev database
*.db
*.db-journal
.pi/

View File

@@ -0,0 +1,333 @@
# Pledge Now, Pay Later — Brand Guide
> **This is the single source of truth.** Every UI change, every new page, every generated asset must follow this document. When in doubt, refer to the visual assets in `brand/`.
---
## Brand Assets Location
All brand assets live in the `brand/` directory at the project root:
```
brand/
├── photography/ 20 landing page photos (Gemini 3 Pro)
├── logo/ 6 logo variations
├── color-palette/ 3 palette reference cards
├── typography/ 3 type specimen sheets
├── moodboard/ 3 visual direction boards
├── brand-guide/ 7 brand guide pages
├── social-templates/ 4 social media templates
└── icons/ 6 line icons in brand colors
```
Production images served from `public/images/landing/` (copied from `brand/photography/`).
### Asset Generation Rules
- **Photography**: Generate with `gemini-3-pro-image-preview` (Nano Banana Pro). Documentary candid style. Save to `brand/photography/` AND `public/images/landing/`.
- **Icons**: Line-only, 2px stroke, single brand color, white background. Save to `brand/icons/`.
- **Social templates**: Use brand colors + Inter typography only. Save to `brand/social-templates/`.
- **Any new visual asset**: Save to the appropriate `brand/` subfolder first, then copy to `public/` if needed for the website.
---
## Core Identity
**Name:** Pledge Now, Pay Later
**Archetype:** The Steward — quiet authority, precision with warmth, trusted with money
**Insight:** People don't break promises. Systems do.
**Promise:** Every pledge tracked. Every donor reminded. Every penny accounted for.
### What We Are
The missing infrastructure between "I'll donate" and the money arriving.
### What We Are Not
- Not a payment processor
- Not a CRM
- Not a fundraising platform
- Not a charity website builder
---
## Color System
Every color has a psychological job. No decorative color usage.
| Token | Hex | Name | Psychological Job | Use For |
|-------|-----|------|-------------------|---------|
| `midnight` | `#111827` | Midnight | Authority | Primary text, dark sections, logo, default buttons |
| `promise-blue` | `#1E40AF` | Promise Blue | Action | Links, CTAs, active states, interactive elements |
| `generosity-gold` | `#F59E0B` | Generosity Gold | Warmth | Pending states, highlights, volunteer accent |
| `fulfilled-green` | `#16A34A` | Fulfilled Green | Completion | Paid badges, success states, confirmations |
| `alert-red` | `#DC2626` | Alert Red | Urgency | Overdue, errors, needs-attention (never decorative) |
| `paper` | `#F9FAFB` | Paper | Calm | Page backgrounds, alternating rows |
### Legacy Aliases (still in tailwind config)
`trust-blue` = `promise-blue`, `warm-amber` = `generosity-gold`, `success-green` = `fulfilled-green`, `danger-red` = `alert-red`
### 60-30-10 Rule
- **60%** Midnight + Paper (the base — dark text on light, or white text on dark)
- **30%** Promise Blue (the action layer — everything interactive)
- **10%** Gold + Green + Red (status indicators only)
### Reference
- `brand/color-palette/01-primary-palette.jpg` — 6 color swatches
- `brand/color-palette/02-tints-and-shades.jpg` — extended scales
- `brand/color-palette/03-color-psychology.jpg` — psychological roles
---
## Typography
**Font:** Inter (Google Fonts)
**Import:** `@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap')`
| Level | Size | Weight | Tracking | Tailwind |
|-------|------|--------|----------|----------|
| Display | 72-96px | Black (900) | -0.025em | `text-7xl md:text-9xl font-black tracking-tighter` |
| H1 | 48-60px | Black (900) | -0.02em | `text-4xl md:text-5xl font-black tracking-tight` |
| H2 | 30-36px | ExtraBold (800) | -0.015em | `text-3xl font-black tracking-tight` |
| H3 | 18-24px | Bold (700) | normal | `text-lg font-bold` or `text-base font-bold` |
| Body | 14-16px | Regular (400) | normal | `text-sm` or `text-base` |
| Caption | 11-12px | Medium (500) | 0.05em | `text-xs font-medium tracking-wide` |
### Numbered Steps Pattern
Use `01`, `02`, `03` in Display/large size as visual anchors instead of icons:
```jsx
<p className="text-4xl font-black text-gray-200">01</p>
```
### Reference
- `brand/typography/01-typeface-specimen.jpg`
- `brand/typography/02-heading-scale.jpg`
- `brand/typography/03-numbers-in-brand.jpg`
---
## Logo
**Mark:** Solid square, zero border-radius, `bg-midnight`, white "P" in Inter Black.
```jsx
{/* Standard logo mark */}
<div className="h-7 w-7 bg-midnight flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
</div>
{/* With wordmark */}
<div className="flex items-center gap-2.5">
<div className="h-7 w-7 bg-midnight flex items-center justify-center">
<span className="text-white text-xs font-black">P</span>
</div>
<span className="font-black text-sm tracking-tight">Pledge Now, Pay Later</span>
</div>
```
### Rules
- Never round the mark corners
- Never add shadows or gradients to the mark
- Never use emoji (🤲) as a logo substitute
- Minimum clear space = 1.5× height of mark on all sides
- Three versions: dark on white, white on dark, blue mark on white
### Reference
- `brand/logo/01-lockup-dark-on-white.jpg` — primary
- `brand/logo/02-lockup-white-on-dark.jpg` — reversed
- `brand/logo/03-lockup-blue-mark.jpg` — blue accent
- `brand/logo/04-mark-dark.jpg` — mark only
- `brand/logo/05-mark-blue.jpg` — blue mark only
- `brand/logo/06-favicon.jpg` — edge-to-edge favicon
---
## Design Rules
### DO
```
✓ Sharp edges everywhere (rounded-lg max on interactive elements)
✓ Typography as the hero — headlines readable without images
✓ Numbered steps (01, 02, 03) instead of icons
✓ Dark sections (bg-gray-950) for key stats and CTAs (max 2 per page)
✓ border-l-2 accents for feature lists
✓ gap-px grids for comparisons
✓ Solid flat colors only
✓ Generous whitespace
```
### DO NOT
```
✗ rounded-2xl, rounded-3xl, rounded-full (except avatars/progress bars)
✗ bg-gradient-to-* anything
✗ shadow-lg shadow-{color}/25 (colored shadows)
✗ backdrop-blur, glass effects
✗ Emoji as visual anchors in headings
✗ group-hover:scale-105 or any scale animations
✗ animate-pulse-ring, animate-bounce-gentle (decorative animations)
✗ More than 2 dark sections per page
✗ Color on heading text — color goes on borders, badges, backgrounds
✗ "AI-powered" as a feature name
```
### Three Signature UI Patterns
**1. Left-Border Accent**
```jsx
<div className="border-l-2 border-midnight pl-4">
<p className="text-sm font-bold text-midnight">Feature name</p>
<p className="text-xs text-gray-500">Description text</p>
</div>
```
**2. Gap-Px Grid**
```jsx
<div className="grid md:grid-cols-3 gap-px bg-gray-200">
<div className="bg-white p-6">Cell 1</div>
<div className="bg-white p-6">Cell 2</div>
<div className="bg-white p-6">Cell 3</div>
</div>
```
**3. Dark Inversion**
```jsx
<section className="bg-gray-950 py-20 px-6">
<h2 className="text-white font-black">Key stat or CTA</h2>
</section>
```
### Reference
- `brand/brand-guide/07-ui-patterns.jpg` — all three patterns visualized
---
## Button Variants
```jsx
<Button variant="default"> {/* bg-midnight, white text */}
<Button variant="blue"> {/* bg-promise-blue, white text */}
<Button variant="success"> {/* bg-fulfilled-green, white text */}
<Button variant="amber"> {/* bg-generosity-gold, white text */}
<Button variant="destructive"> {/* bg-alert-red, white text */}
<Button variant="outline"> {/* border, white bg */}
<Button variant="ghost"> {/* transparent, hover gray */}
<Button variant="link"> {/* promise-blue underline */}
```
No button has colored shadows. No button has rounded corners beyond the base `--radius` (0.5rem).
---
## Photography Direction
**Style:** Documentary candid — never staged, never stock.
**Technical:**
- Shallow depth of field (f/1.4f/2.8)
- Available/natural light (warm tungsten indoors, overcast outdoors)
- Candid angles (never looking at camera)
- British-diverse subjects (South Asian, Black British, Arab, white British)
- Real settings (mosques, community centres, galas, homes, London streets)
**Never show:**
- Stock handshakes
- People pointing at screens and smiling
- Overhead "team meeting" shots
- Poverty imagery
- Everyone looking at camera
**Generation prompt template:**
```
[Scene description]. [Subject description — ethnicity, age, clothing].
[Setting — location, lighting]. [Camera — lens, f-stop, style].
Shot on [camera], [focal length], f/[aperture], available light.
[Mood]. [Aspect] aspect ratio.
```
### Reference
- `brand/photography/` — 20 generated photos
- `brand/moodboard/01-trust-and-precision.jpg`
- `brand/moodboard/02-community-and-giving.jpg`
- `brand/brand-guide/06-photography-direction.jpg`
---
## Voice & Tone
### Voice (constant)
- **Direct.** Short sentences. No filler.
- **Specific.** Numbers, not vague claims. "60-second pledge flow" not "quick and easy."
- **Empathetic.** We understand the awkwardness. Never shame.
- **Confident.** "We fix that." Not "We can help with that."
### Words We Use
| Use | Don't Use |
|-----|-----------|
| Pledge | Donation (for the promise stage) |
| Campaign | Event (broader than physical events) |
| Pledge link | QR code (QR is one delivery method) |
| Reminder | Chaser, follow-up |
| Conversion | Collection rate |
### Words We Never Use
- "Revolutionary" / "game-changing" / "disruptive"
- "Powered by AI"
- "PNPL" in user-facing copy (internal + bank refs only)
- "Simple" / "easy" (show, don't tell)
---
## Social Templates
- `brand/social-templates/01-og-image.jpg` — Open Graph (link previews)
- `brand/social-templates/02-instagram-square.jpg` — Instagram post
- `brand/social-templates/03-story-template.jpg` — WhatsApp/IG Story
- `brand/social-templates/04-linkedin-banner.jpg` — LinkedIn company page
---
## Icons
Line-only icons, 2px stroke, single brand color per icon:
| Icon | File | Color |
|------|------|-------|
| Pledge/Promise | `brand/icons/01-icon-pledge.jpg` | Midnight |
| WhatsApp/Send | `brand/icons/02-icon-whatsapp.jpg` | Fulfilled Green |
| Gift Aid | `brand/icons/03-icon-gift-aid.jpg` | Promise Blue |
| Zakat | `brand/icons/04-icon-zakat.jpg` | Generosity Gold |
| Dashboard | `brand/icons/05-icon-dashboard.jpg` | Midnight |
| Schedule | `brand/icons/06-icon-schedule.jpg` | Midnight |
---
## CSS Variables
```css
:root {
--background: 0 0% 100%; /* white */
--foreground: 222 47% 11%; /* midnight */
--primary: 224 76% 40%; /* promise-blue */
--primary-foreground: 0 0% 100%; /* white */
--destructive: 0 72% 51%; /* alert-red */
--border: 220 13% 91%; /* gray-200 */
--radius: 0.5rem; /* max corner radius */
}
```
---
## Allowed Animations
Only these — nothing decorative:
| Class | Use |
|-------|-----|
| `animate-fade-up` | Page sections appearing on load |
| `animate-fade-in` | Elements becoming visible |
| `animate-slide-down` | Dropdowns, notifications |
| `animate-shimmer` | Loading skeleton states |
| `animate-counter-roll` | Number counters |
| `stagger-children` | Sequential card reveals |
---
*Brand Guide v1.0 — March 2026*
*When adding a new page or component, re-read this file first.*

View File

@@ -1,9 +1,10 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
RUN --mount=type=cache,target=/root/.npm npm ci
FROM base AS builder
WORKDIR /app
@@ -15,12 +16,14 @@ RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache libc6-compat
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
ENV NEXT_SHARP_PATH=/app/node_modules/sharp
USER nextjs
EXPOSE 3000
ENV PORT=3000

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Some files were not shown because too many files have changed in this diff Show More