Repository navigation
Permalink
Choose a base ref
{{ refName }}
default
Choose a head ref
{{ refName }}
default
Comparing changes
Choose two branches to see what’s changed or to start a new pull request.
If you need to, you can also or
learn more about diff comparisons.
Open a pull request
Create a new pull request by comparing changes across two branches. If you need to, you can also .
Learn more about diff comparisons here.
base repository: pullfrog/pullfrog
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v0.1.47
Could not load branches
Nothing to show
Loading
Could not load tags
Nothing to show
{{ refName }}
default
Loading
...
head repository: pullfrog/pullfrog
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v0.1.48
Could not load branches
Nothing to show
Loading
Could not load tags
Nothing to show
{{ refName }}
default
Loading
- 3 commits
- 37 files changed
- 2 contributors
Commits on Aug 2, 2026
-
feat(billing): introduce dormant $30 Pro plan for organizations (#1022)
* feat(models): generic OpenAI-compatible BYOK provider * address review: rename env contract to OPENAI_COMPATIBLE_*, add console setup callout, derive byok from routing, docs fixes * feat(billing): dormant org commercial gate foundation (Team plan, flat $30) Fork-independent foundation for charging orgs a flat commercial-use subscription, shipped inert behind a global kill-switch (isCommercialGateEnabled() defaults false) so prod behavior is unchanged until launch. Individuals stay free; orgs get a first-run-anchored one-month trial then a plan. - schema: Subscription model (one row per account+product, add-on stacks), Account.accountType, enums, priceCents grandfather lever; metadata-only migration. - domain (utils/billing/subscriptions.ts): isCommerciallyGated (the fork switch — flat isOrg today; BYOK variant is a one-line change), resolveTeamRunAccess (exhaustive, fail-closed verdict), newTrialData, advanceWindDown (pure transitions). - two run gates on one shared verdict: reserveRun (friendly, early; starts trial + bypasses cap/AR via billableCents=0 — no double-billing) and run-context (universal 402 backstop for BYOK/manual re-runs; also starts the trial) + action-side 402 -> BillingError abort. - wind-down cron (trialing -> grace -> paused) + workflow + customer email; send-before-stamp + status-guarded update. - accountType set at install + backfill script; PaywallError extended. - docs: wiki/billing-model-v2.md, RESOLVED-SPEC.md, AGENTS/scripts index. Analysis + resolved plan in .plans/billing-model-v2/. Launch (Stripe subscription writer, Stripe Tax, Router-free-during-trial, flip the kill-switch) is gated on the human decisions in RESOLVED-SPEC.md §8. * anneal round 2: wind-down honesty, CTA consistency, doc accuracy - windDown: only stamp warningEmailedAt when an email actually sent (a later resolvable address re-warns); ops alert when an org has no reachable contact, so a pause is never a fully silent cutoff. - point every commercial-gate CTA (paywall comment, action summary, subscription_unpaid, wind-down email) at the #model-access card-management anchor, matching the existing delinquent-paywall convention. - wiki: correct the reinstall-safety citation (trial lives on Subscription, not the account upsert), align grace copy to the email-only reality (no in-PR escalating warning shipped), document the run-context 402 version-skew caveat, the fork-anchors-trial note, and the uninstall-nag + subscribe-affordance launch items. no prod behavior change (gate still dormant). typecheck + lint green. * anneal round 3: drop redundant index, tighten module surface, doc accuracy - Subscription: drop @@index([accountId]) — the @@unique([accountId, product]) composite already serves every accountId-prefix lookup + the FK delete-check, so the standalone index was pure write-amplification. Migration regenerated (drift empty). - subscriptions.ts: un-export TRIAL_DAYS, SubjectAccount, TeamRunVerdict (no cross-module consumer — module-local); note TEAM_PRICE_CENTS is intentionally unconsumed until the deferred Stripe writer lands. - schema: correct trialEndsAt comment ("first run, any outcome" not "first successful run") to match the implementation. no prod behavior change. typecheck + lint green. * anneal round 4: cron poison-pill isolation, observability, honest enforcement docs - wind-down cron: per-item error isolation (a failing subscription no longer wedges the oldest-first sweep) + best-effort email (trySendWindDownEmail never throws; ops-alert on failure, transition proceeds) + log when the status-guard skips a converted org + swept-count line. - observability: log the run-context 402 refusal (the backstop path on-call was blind to) and trial creation in both gates (count>0); new operator diagnostic scripts/subscription-states.ts (+ wiki/scripts.md row) so org billing state isn't a prisma-only black box. - honest enforcement docs: add the accountType backfill as a THIRD hard arming prerequisite (arming before it fails OPEN across all existing orgs); document the gate's real reach (cooperative for self-triggered BYOK, version-skew, fail-open-on-infra-error, new-org serial trials, team-on-personal-repos, proxy-token not a gate point) in a wiki "Enforcement reach & bounded leaks" section; soften the "universal backstop" comments to match. no prod behavior change (gate dormant). typecheck + lint + action build green. * anneal round 5: claim-first wind-down (kills double-send), copy + docs - windDown: reorder to CLAIM-FIRST — the status-guarded transition commits BEFORE the warning email sends. Fixes a round-4 regression where a write failure after a successful send deferred to the next daily sweep and re-sent the customer email just outside Resend's 24h dedup window; also stops a wrongful "trial ended" email to a just-converted org. advanceOneSubscription now returns whether it transitioned. - cron: bounded in-run yes.op retry before per-item skip (recovers transient DB blips instead of deferring 24h); sweep logs the TRUE transitioned count + due count, not just due. - copy: paused-state surfaces say "resume reviews" not "keep reviewing" (reviews have stopped at that point); wind-down email uses a labeled CTA anchor + the signature "bring your own key, we never touch your tokens" line. - docs: correct the fetchWindDownDueIds ordering comment (oldest-updated, not oldest-deadline); add launch-checklist items for isInfraCovered on covered orgs + the legacy-delinquency-during-grandfather policy call. no prod behavior change (gate dormant). typecheck + lint + action build green. * anneal round 6: simplify (remove now-dead sentinel), seed script, env toggle Revert bloat that accumulated across rounds now that claim-first made it dead: - remove Subscription.warningEmailedAt entirely (column + guard + second write + select + script display + comments + migration). After the round-5 claim-first refactor the status-guarded trialing→grace claim already fires the email exactly once, so the sentinel was provably inert. advanceOneSubscription folds to a single write + a fire-and-forget best-effort email. - merge trySendWindDownEmail + sendTrialWindDownEmail into one sendWindDownEmail (the split existed only for a try/catch); inline the one-use isOrgAccount. - kill-switch: OR-in PULLFROG_TEAM_GATE_ENABLED env override so a preview deploy can arm the gate for e2e without merging (const stays the launch switch). - new scripts/subscription-seed.ts (+ wiki row): put an org into any state locally for gate testing; doubles as the launch "seed trial rows" utility. - wiki: "Testing locally" section; drop stale sentinel/escalating references. net simplification. typecheck + lint green. * anneal round 7: fix sendWindDownEmail throw scope, doc reconciliation - sendWindDownEmail: widen the try/catch to the ENTIRE body. Previously only the sendEmail call was guarded, so a transient throw in the account lookup / contact / greeting resolution (which runs AFTER the claim commits) escaped to the yes.op retry, which re-read `grace` and never re-entered the branch — silently dropping the warning (and the ops alert). Now any throw routes to alertUnreachable; the "never throws / never a silent cutoff" invariant holds. - schema: correct the accountType backfill comment (listInstallations / backfill-account-types.ts, not getInstallation). - kill-switch JSDoc: note PULLFROG_TEAM_GATE_ENABLED must be Preview-scoped (a Production-scope env var bypasses PR/deploy review); preview→prod is structurally isolated (Neon preview branch, crons prod-only). - RESOLVED-SPEC: frozen-artifact banner reconciling renamed/dropped symbols (isCommerciallyGated, TEAM_PRICE_CENTS, dropped sentinel + accountId index) so a future fork-flip greps the right names; wiki is the shipped source of truth. no prod behavior change (gate dormant). typecheck + lint green. * anneal round 8: grace-window honesty, launch seeding safety - advanceWindDown: anchor graceEndsAt to the grace-ENTRY instant (now), not trialEndsAt. A delayed cron would otherwise shrink grace by the delay (a >=14-day cron outage collapsed it to ~0 while the email still promised 14). Safe because the trialing→grace claim is status-guarded (fires once). - subscription-seed.ts: add `--spread <days>` to fan out bulk-seeded trialing expiries — seeding the whole base with one identical trialEndsAt would mass-wind-down (email + pause) the entire base in a single daily sweep. - launch checklist: seed existing PAYING/grandfathered orgs to `active` (not a stock 30-day trial) so the 90-day grandfather isn't contradicted; use --spread for bulk trials; state the safe arming sequence (backfill → seed → arm). - kill-switch JSDoc: invariant for the future Stripe writer — a `trialing` row MUST carry trialEndsAt (a null one is invisible to the cron → free forever). no prod behavior change (gate dormant). typecheck + lint green. * anneal round 9: rollback-order note; PR body refreshed to shipped state round 9 lenses (concurrency deep-dive, prod-migration-safety) came back sound — no races, migration is metadata-only + CI-drift-guarded + deploy-ordered. Two follow-ups: - wiki: add the migration rollback-order note (revert code before dropping accountType — the install webhook writes it unconditionally, not gated). - (PR body updated out-of-band) the round-1 description had drifted: it claimed "send-before-stamp" (now the opposite: claim-first, no sentinel), omitted the env toggle + the seed/states scripts, and had a broken relative wiki link. no prod behavior change (gate dormant). * anneal round 10: capstone confirmations + boundary/doc consistency capstone re-runs on the final code came back sound: billing (no money defect), state machine (complete, fail-closed, clean Pullfrog/Stripe ownership split), enforcement (coherent + honestly documented), accessibility/i18n (clean for a pre-launch dormant feature). Two consistency nits fixed: - advanceWindDown: use strict `>` (not `>=`) for both deadline checks, matching the fetch predicate (`lt: now`) and the wiki. Zero behavior change (the fetch already gates the exactly-equal case), removes a latent boundary mismatch. - wiki transition table: add the deferred Stripe-owned recovery edges (paused→active re-subscribe, unpaid→active dunning-recovery) the kill-switch JSDoc mandates, so "any non-terminal → active" is no longer ambiguous about whether paused/unpaid qualify. no prod behavior change (gate dormant). typecheck + lint green. * anneal round 10 (final): ship-readiness verdict MERGE; last doc nit Final round: fresh-eyes correctness + ship-readiness + diff-hygiene lenses. Verdict: MERGE — dormant, self-consistent (schema↔migration↔code↔wiki↔scripts), typecheck + lint green, every remaining item is a documented pre-ARMING launch step (fork choice, Stripe writer + Tax, Router-free-during-trial, subscribe UI, live-install wind-down gate), not a merge blocker. One doc nit fixed: TEAM_PRICE_CENTS JSDoc no longer claims "unconsumed" (subscription-seed.ts uses it). no prod behavior change (gate dormant). typecheck + lint green. * feat(billing): Stripe conversion backend (dormant) — writer + checkout Builds the launch-critical piece every anneal capstone flagged as "the single biggest gap": the paid-state writer that makes paused→active recovery real. Fork-independent (checkout/webhook machinery is identical for flat or BYOK) and dormant — inert until ops publishes a Team Price + the gate is armed. - utils/billing/subscriptionStripe.ts: mapStripeSubscriptionStatus (fail-closed: active→active, active+cancel_at_period_end→canceling, past_due/unpaid→unpaid, canceled/incomplete→paused) + syncSubscriptionFromStripe (idempotent upsert keyed on metadata.accountId+product; ignores non-ours subs). - dispatchStripeEvent: customer.subscription.created|updated|deleted → syncSubscriptionFromStripe; short-circuit the metadata.invoiceId orphan alert for subscription-originated invoices (billing_reason `subscription_*`), else every renewal would spuriously alert. - utils/stripe.ts createTeamCheckoutSession: subscription-mode Checkout for the Team Price (env STRIPE_TEAM_PRICE_ID — throws until ops sets it), automatic_tax + address/tax-id collection on, subscription_data.metadata.accountId so the webhook writer lands on the right row. - app/api/account/[owner]/billing/team-checkout: org-admin-only, org-only, and gate-armed-only route (404 while dormant) returning the Checkout URL. - docs: kill-switch prereq #1 + wiki (writer is BUILT; remaining = publish Price + enable Stripe Tax + subscribe UI); transition table + Files updated. Empirically verified against the dev DB (37/37): the full paused→active→ canceling→unpaid→paused cycle + ignore-non-ours, plus the state machine + both DB writers from the foundation. typecheck + lint green. No prod behavior change (dormant; checkout throws without STRIPE_TEAM_PRICE_ID; webhook no-ops with zero subscription rows). * anneal (stripe backend): event-ordering, current_period_end, FK + double-submit Focused Stripe-lens anneal on the new conversion backend (contract verified vs current docs, writer correctness, security). Fixes: - MAJOR event-ordering: handle customer.subscription.* by RE-RETRIEVING the sub from Stripe and writing its authoritative state, not the event snapshot. Stripe doesn't guarantee order + retries 3d, so a redelivered `updated` (active) landing after `deleted` (canceled) was resurrecting a canceled sub to covered (fail-open). Re-retrieve is order-independent (Stripe's rec) — no new column. - MAJOR current_period_end: read items.data[0].current_period_end first (the Basil API / stripe ^22 moved it off the Subscription onto the item); the top-level read was writing null forever. Mirrors unitPriceCents. - FK guard: skip a subscription whose metadata.accountId has no Account (a foreign/mis-stamped event would FK-violate → Stripe retries forever). - double-submit: team-checkout 409s if the org already has an active/canceling Team sub (no second subscription on a double click). Contract confirmed vs docs: billing_reason startsWith("subscription") correctly skips renewals without catching manual runs_monthly invoices; subscription.* fires for cancel_at_period_end/past_due; subscription_data.metadata lands on the sub. Regression: dev-DB exercise 37/37. typecheck + lint green. * billing comms v2: honest-by-construction email + triaged Colin questions fold the 6-lens adversarial review: fix the ship-a-lie blockers (individuals- free-forever contingency, 'never pay more' cliff, 'no wall' router-trial dep, flat-$30-vs-fork contingency, subject-line never-meter one-way-door, '$30 cheaper' falsehood, 'no metering'/'unlimited' overreach, router-only-org mis-routing). email is now a contingent draft gated on S1/S3/S4/S5/S6/S9 with a DO-NOT-SEND header + a 4th cohort insert (router-only orgs). questions doc triaged (blocking vs confirm-or-defer), merged S2/S3, added personal-cap + SSO-tax + enforceability + sequencing cruxes, pinned attach cohorts, no em-dashes. * billing comms v3: clean email rewrite (legal + competitor + colin/armstrong r2) email rewritten ground-up (was a patchwork after r1/r2): reference a ToS + fair-use page (root legal fix so 'unlimited'/'we will never' are incorporated by reference not standalone perpetual reps); depersonalize competitor callouts (stop dunking on 'unlimited' while using it); grandfather-FOREVER section B (per S9) protecting prepaid credit; scope 'only tokens cost money' to beyond-$30; router cost = 'exactly what OpenRouter charges us' not 'raw provider cost'; drop SSO from the never-block (S6) + 'surprise bill' (auto-reload) + 'hostage' (strawman); add the retroactive-change never-line; C surprise-charge scoped to subscription. questions: narrow S2 to ship-vs-hold timing, S5 table matches the 6 published never-lines + ToS ask, add ToS launch prerequisite. * billing comms v4: fix cohort-routing seam, honesty overstatements, add cruxes email v4 (round 3): fix the blocker contradiction ('only tokens cost money' vs Scale tier + $50 add-on -> 'on the Team plan we don't charge per run'); restructure cohorts (base A + inserts before signoff, personal-only gets A alone, org gets A + one of B/C/D) killing the dangling 'below' pointer + the routing-note contradiction; 'full CORE agent' (Repo Intelligence is paid); split BYOK (truly no-card/any-model) from Router ($10 default-tier credit then card); scope the retroactive never-line to price/plan (not 'all terms', avoids ToS clash); pin section B grandfather to per-run RATE; soften C reach-out to in-context warn. questions: re-triage (S9 blocks section B, S6 doesn't block SSO-less email), reconcile $630-870 observed-floor vs plan's funnel-lifted $1.5-2.3k, add S11 (multi-org billing), S12 (annual), S13 (refund/proration/dunning), flag EU VAT, drop lone S4 blocker tag. * billing comms v5: OSS carve-out (blocker), grandfather scope, cohort precedence round 4: two real blockers + polish. - BLOCKER OSS orgs: shipped gate exempts only accepted-OssApplication repos, so a public-repo org not in the program gets paywalled despite the 'open source free' promise. Add S14 (exclude OSS/public-repo orgs from the gate), gate the email's open-source line on it, add OSS-first to cohort routing. - BLOCKER grandfather-to-everyone: 'your price is grandfathered' shipped in base A to $0 orgs about to be charged $30. Scope the never-line to 'a price you are already paying' (announced-ahead intro is not a stealth raise). - fix S3 gate on wrong cohort: split C (BYOK trial, no Router wall, no S3) from D (Router trial, S3-gated, allowance warning); B-vs-D precedence + segment the 69 cardholders by card purpose. - kill self-dunks: 'per-run fee for Pullfrog itself' (not 'meter on runs'), drop 'unlimited' -> 'no per-run caps for normal team use', 'per GitHub org'. - universal closing beat for individuals; label B's rate 'existing per-run rate'. - questions: add S14 (OSS), S15 (paused-org data retention + Router refund). * docs(billing-v2): round-5 fold — per-repo public-repo OSS exemption (S14), S5 verbatim never-lines, S2 first-gate banner * docs+copy(billing-v2): round-6 fold — fix inverted grandfather never-line, public-repos-free carve-out, S9 architectural gap (grandfather has no code state), drop 'unlimited' from 3 shipped gate surfaces, cohort-neutral wind-down email * docs(billing-v2): round-6 fold cont. — S5 verbatim sync + successor-entity 'ever' scope + copy-surface audit, S13 ARL/auto-renewal disclosure * docs(billing-v2): remove questions-for-colin.md from repo (delivered in chat instead) * docs(billing-v2): fold standup decisions — rip grandfather, flat $30 confirmed, $10-credit trial, Aug-1 generous transition; simplify email to one handoff note * feat(billing-v2): rip per-run cap/charge from reserveRun (individuals free) standup: rip out all old per-run billing. reserveRun now: OSS/personal/ disarmed-orgs run free, armed orgs run the flat $30 team gate. removes the 30-run cap, $0.07/run charge, free-tier 30->100 bump, and the runs-delinquency gate. PaywallError reshaped to the two team-plan reasons (commercial / subscription_unpaid); cap/delinquent reasons + fields gone. billableCents kept (always 0) until a follow-up migration drops the dead columns. also drops the contradictory 'Router-free-during-trial' arming prereq: the free month is only the waived $30, not free Router usage ($10 credit then pay-as-you-go). * feat(billing-v2): delete per-run invoicing subsystem + strip billing UI deletes maybeInvoiceAccount, run-monthly-billing, delinquency, runs-invoice workflow+cron, bill-now/bill-all/dev-bill scripts, freeTierBump email + the dev billing-states harness. rewrites the billing DTO (route) to drop cap/accrued/delinquency (keep wallet/cards/invoices/grants), and strips the runs-cap + delinquency + promo UI from BillingCard (PlanRow now shows card status honestly). removes the lifecycle snapshot-bill from the installation webhook. NOTE: a flat-$30 team-plan panel is a follow-up additive UI increment (needs visual verification); this increment keeps the page coherent + green. * feat(billing-v2): remove dead exports + promo system + runs-invoice cron removes CAP_CONSUMING_STATUS + runPaymentState (no consumers after the rip), the runs-invoice vercel cron entry, and the entire unlimited_runs promo system (registry, apply, PromoCookieSetter, callback + layout wiring) whose only reward was raising the now-gone run cap. * docs(billing-v2): fix stale reserveRun/startOfUtcMonth JSDoc after per-run rip * docs(billing-v2): update wiki for the rip — runs never metered, no grandfather, generous Aug-1 transition, trial not free-Router * feat(billing-v2): S14 public repos are free adds Repo.private (fail-closed default true, folded into the billing-v2 migration), synced from GitHub via the repository_privatized/publicized webhooks + scripts/backfill-repo-visibility.ts (launch prereq). both run gates (reserveRun + run-context) now short-circuit free on `oss || repo.private === false`, so a public repo is never gated — making 'open source is free' literally true. also marks the vestigial per-run cap/cost columns dead in the schema. * feat(billing-v2): Aug-1 transition seed anchor (--trial-ends) subscription-seed.ts gains --trial-ends <YYYY-MM-DD> to pin a trialing trialEndsAt to an exact UTC date. this is the generous launch transition: seed existing orgs 'trialing --trial-ends <first-of-next-month>' so they run free the rest of the current month and convert on the 1st (the trialing window IS the waived-$30 month). also refactors flag parsing to a takeFlag helper. * feat(billing-v2): #9 Stripe test-clock lifecycle sim (scripts/stripe-sim-team.ts) drives the REAL paid Team lifecycle (checkout → active → renewal → dunning → unpaid) against the Stripe sandbox via test clocks, so customer.subscription.* webhooks flow through the production writer chain and assert on the local Subscription.status the run gates read. Documents the two-time-axis testing recipe (DB-time wind-down vs Stripe-clock paid lifecycle) in the wiki. * fix(billing-v2): fold final-review findings - S14 leak (major): fetchOrCreateRepo now writes Repo.private from GitHub visibility (threaded via getEnrichedRepo + handleWebhook) + self-heals on the fast path, so public repos created AFTER the one-time backfill aren't wrongly gated. narrows the publicized/privatized webhook catch to P2025 so real transitions aren't silently dropped. - dead per-run gate: DELETE /payment-method no longer blocks on billableCents (nothing sets it; the bill-now/cron remediations are gone) — card removal is Stripe-portal-driven now. - customer copy vs shipped: homepage PricingSection ($0.07/run tier → flat $30 Team), docs/billing.mdx (per-run model → v2), card-welcome modal (drop the removed included-runs cap). email note B rewritten opt-IN (no auto-charge; do nothing → pause, not bill); waive-old-fees marked settled (code can't bill). * docs(billing-v2): last stragglers — comparisons.mdx pay-as-you-go → flat, stale homepage tier comment * fix(billing-v2): Repo.private as its own migration (was silently un-applied) folding the ADD COLUMN into the already-applied billing-v2 migration meant it never ran on any DB that had the original applied (Prisma tracks by name) — migrate status said 'up to date' while the column was missing, so every repo.private query would crash at runtime. moved it to a clean standalone 20260708140000_add_repo_visibility migration that applies fresh on prod + preview branches. drift verified empty. * fix(billing-v2): fold deeper-review findings (concurrency major + stragglers) - MAJOR race: removed the fetchOrCreateRepo self-heal — it was last-writer-wins over UNORDERED webhook snapshots (a stale push event could revert a 'privatized' event → free runs on a private repo when armed). Repo.private is now written only at create + by the ordered privatized/publicized events + backfill. run-context reads LIVE githubRepo.private (already fetched) as the authoritative, order-independent gate backstop. - threaded isPrivate through the push-branch fetchOrCreateRepo (the last create path that ignored it); transition handler now invalidates caches first + swallow-logs (redelivery is deduped upstream, so rethrow was a no-op). - copy: docs/comparisons.mdx (2 more 'pay-as-you-go' spots), deleted vestigial emails/announceBilling.ts, corrected 2 stale Stripe op-alerts. - wiki: documented the order-independent visibility-read invariant. * fix(billing-v2): fold review-3 (convergence) — complete the S14 create-population - MAJOR: the self-heal removal exposed 3 wizard/onboarding create paths (verify-workflow, createWorkflowForRepo + PR branch, promoteRepoToActive) that never threaded isPrivate — a public repo onboarded via console/CLI was written private=true and reserveRun would paywall it. Threaded isPrivate through all of them + their 3 route callers; all 6 fetchOrCreateRepo call sites now populate it. - corrected the 2 self-contradictory comments (handleWebhook + fetchOrCreateRepo) that still claimed the deleted self-heal. - copy: homepage 'Unlimited runs' → 'Unmetered runs', docs/getting-started + billing.mdx unlimited→unmetered, comparisons already fixed. - wiki hygiene: removed dead script/email rows, /dev/billing-states example, deprecation notes on billing.md + workflows.md, stale BillingCard + paywall JSDocs. - launch checklist: added terms-rewrite-with-pricing-flip + team-checkout double-submit hardening. * fix(billing-v2): for-oss repo.upsert seeds private=false (S14, definitionally public) the one repo-creation path outside fetchOrCreateRepo — OSS-eligible repos are privacy:PUBLIC by definition (getViewerEligibleRepos), so seed the row public instead of the fail-closed private=true default that would wrongly gate it if the OSS app is later rejected + the gate armed. review-4 convergence: S14 create-population confirmed complete across all other paths. * chore(billing-v2): fold Colin's final nits — dead-comment cleanup trims the vestigial void-reservation comment (referenced the deleted monthly cron + wrong enum) + drops its no-op billableCents zero-out; fixes 2 stale comment pointers to the deleted maybeInvoiceAccount.ts. Colin final review: SHIP (no merge blockers). migrations deliberately left as two additive files (folding into the shared-dev-applied subscriptions migration would need a destructive reset; both apply cleanly fresh on prod + preview). * fix(billing-v2): anneal round-1 findings MAJOR: - wind-down cron now gated on isCommercialGateEnabled() — an emergency disarm AFTER arming no longer keeps pausing orgs + emailing them while runs flow free (restores 'gate off => fully inert' post-arm). - team-checkout blocks 'unpaid' orgs (live delinquent Stripe sub) from minting a DUPLICATE subscription; directs them to the portal to repair the existing one. - mapStripeSubscriptionStatus: 'past_due' now maps to covered ('active'), not refused — Stripe is still Smart-Retrying (~2wk) and the card may recover; only terminal 'unpaid' refuses. matches the gracious-dunning spec. verified 8/8. DOC/minor: - wiki/pricing.md superseded-notice (billing.md pointed to it as canonical); AGENTS.md drops stale 'Stripe runs invoicing'; docs de-beta the $10 credit + trim OSS 'no monthly cap'; reserveRun documents the deliberate mirror-vs-live gate divergence; subscription-seed resolves orgs by (name, org) not bare name; AccountPlan 'payg' comment de-stale'd. * docs(billing-v2): anneal round-2 — document the 'Mark as unpaid' Stripe arming prereq round-2 caught that the whole 'unpaid' path (the team-checkout unpaid 409, the subscription_unpaid paywall, the past_due→covered→unpaid framing) is UNREACHABLE under Stripe's DEFAULT dunning end-behavior ('Cancel' → canceled → paused). the recoverable-unpaid UX the design wants needs Stripe set to 'Mark as unpaid'. adds it as arming prereq #3 (subscriptions.ts + wiki checklist), fixes the self-contradictory RESOLVED-SPEC state row (past_due→canceled → past_due→unpaid), and documents the disarm→re-arm catch-up as benign (grace anchored to now + email idempotency). code is fail-closed either way; this is copy/UX + docs. * feat(billing-v2): frictionless opt-in Team-plan subscribe CTA in the Billing card surfaces the existing hosted-checkout route in the org console: a dormant Team-plan section (renders only when the commercial gate is armed for an org) with a badge + status-driven CTA — subscribe (hosted Checkout, saved card reused) for untried/trialing/grace/paused, manage/update-card (portal) for active/canceling/unpaid. adds teamPlan to the billing DTO (skipped on the common path) and lands the checkout redirect on #billing. * refactor(billing-v2): Team-plan subscribe CTA as idiomatic server-redirect page replaces the bespoke client fetch(POST)+window.location subscribe button with a plain <a> into /team-subscribe/<owner>, a server page that mints the hosted Checkout session and redirects — mirroring the sibling /enable-billing, /billing-top-up, /billing-portal flows. drops the pending state, error toast, and JSON contract; deletes the now-unused team-checkout POST route. * TEMP: froggerton god mode for billing-v2 preview verification (REVERT before merge) * feat(billing-v2): dedicated Team-plan card (trial countdown + value prop + per-state CTA) replaces the thin status pill in the Billing card with a real conversion surface: its own card on the org console (above Billing), self-gating to org + armed-gate + billing-admin. per-state copy with a trial/grace countdown, the flat-$30 value prop, a renewal/ends line for paid states, and a prominent CTA (subscribe via /team-subscribe, or manage/fix via the Stripe portal). extends the billing DTO's teamPlan with trialEndsAt/graceEndsAt/currentPeriodEnd. * refactor(billing-v2): fold Team plan into Billing card as its headline (unify) the plan IS billing — a separate card fragmented one story. moves the full-weight plan block (badge, trial/grace countdown, value prop, per-state CTA) to the top of the Billing card as its lead section, and deletes the standalone TeamPlanCard + its console wiring. Model costs stays its own card (a different axis: how model tokens are paid, at cost). * refactor(billing-v2): merge Billing + Model costs into one card (progressive disclosure) collapses the two money cards into a single Billing card with three status-first sections — Team plan (headline) / Models (Router|BYOK) / Payment (one line) — and moves the auto-reload knobs + BYOK keys behind an inline 'Manage' expand. kills the duplicate payment rows, the two 'enable billing' buttons, and the noisy $0-$60 balance bar. deletes ModelAccessCard + its console section/nav; retargets stale #model-access deep-links to #billing. all Router wallet flows (top-up, redeem, auto-reload, monthly limit, BYOK secrets) preserved. * fix(billing-v2): fold adversarial-review findings from the card merge - re-mount BillingConfirmationModal in BillingCard (both locked + admin branches): the deleted Model costs card was its only mount on the owner console, so welcome-credit / top-up-success / top-up-failed / owner-required post-Stripe confirmations were silently broken + URL params left lingering. - complete the #model-access → #billing retarget the merge missed: the top-up callback hash, the 3 dunning emails (lowBalance/routerLimitReached/ trialWindDown), and the 6 live PR-comment CTAs in action/utils/billingErrors.ts (billingConsoleUrl loses its now-single-valued anchor param). - drop the duplicate 'Enable billing' button in the no-card Router prompt (Payment section owns the single add-card CTA). - fix the pre-existing auto-reload toggle desync: saveSettings returns a bool so the optimistic flip actually rolls back on PATCH failure (the old .catch() was dead code). * polish(billing-v2): design round 1 — header hierarchy, copy, consistency - Team-plan header: status pill + time detail now sit inline on the header line beside the 'Team plan' label (matching Models/Payment), CTA pins right, value prop full-width below; untried state drops the low-value 'Not subscribed' pill entirely. - countdown split into badge ('Free trial') + muted aside ('12 days left'). - CTA tightened 'Start the Team plan · $30/mo' → 'Start · $30/mo' (redundant under the Team plan label). - section labels (Team plan/Models/Payment) → font-medium for a consistent ramp. - panel field labels drop the redundant prefix: 'Auto-reload amount'→'Amount', 'Auto-reload threshold'→'Threshold'. - inline expander renamed 'Manage'→'Edit' (avoids collision with the 'Manage plan' portal CTA). - removed all 6 em-dashes from product copy (house rule); 'always free' consistent everywhere; trimmed redundant/marketing copy tails. * polish(billing-v2): design round 2 — tone the $10 credit callout the beta-credit callout was a taller two-line box that visually dominated the Models section over the plan + balance. collapse to a compact single line ($10 in free Router credit to use on any model) with a smaller Redeem button; drop the redundant trailing spacer (the caller owns the gap). * docs(billing-v2): unified Billing card screenshots — all plan states pixel-verified against the live preview (froggerton god-mode login): untried / trialing / grace / paused / active / canceling / unpaid. grace also shows the BYOK secrets panel. referenced from the PR body. * refactor(billing-v2): Billing section header (shared card) + Team plan + Model usage cards restructures the one flat card into the agreed IA: a top-level 'Billing' section header carrying the SHARED payment method (funds everything), then two focused cards beneath it — 'Team plan' (what you pay Pullfrog) and 'Model usage' (Router / BYOK token billing). fixes the orphaned-payment + monotonous-flat-list problems: the card is now the umbrella, and the two payment targets are visually distinct cards instead of identical rows. Team plan card is conditional (org + armed gate + admin), so a personal console degrades to header + Model usage card only. * docs(billing-v2): refresh screenshots for the header + two-cards layout Billing section header (shared payment method) + Team plan card + Model usage card, all 7 plan states, pixel-verified against the live preview. * fix(billing-v2): payment method is optional, not a gate (kill 'Enable billing') 'Enable billing' told two lies: the WORD (nothing is disabled — Router runs on the free $10 credit, BYOK needs no card, public/personal repos are free; the gate is hasCardOnFile || balance > 0) and the EMPHASIS (a filled primary button reads as 'the required next step'). Fix both, keep the shared card in the header where it belongs (it funds both Team plan + Router): - add/update affordances → quiet evergreen text-links (match the 'Edit' weight), labels 'Add card' / 'Update'. no filled buttons. - header InfoPopover rewritten to the honest gate map (card optional; only for Router-past-credit or Team renewals). - Router no-card copy: passive 'Runs use your Router credit. Add a card to auto-reload when it runs low.' + a $0 escalation ('Free credit spent. Add a card to resume Router runs, or switch to BYOK.') — the one genuinely-gated moment, and it names the cardless path. - /enable-billing page title → 'Add a payment method' so the handoff stays honest. - delete dead needsPaymentMethod DTO field (zero consumers, asserts a need the system doesn't impose). (synthesized from a 5-proposal design fan-out; 'Enable billing' also appears as feature-lock CTAs in AgentSettings/LearningsSection — different surface, left alone.) * fix(billing-v2): suppress 'free credit spent' while the $10 is still redeemable + refresh stale BillingCard JSDoc - the $0-no-card Router escalation now gates on !signupCreditEligible: don't say 'free credit spent' when the user hasn't claimed their $10 yet (the Redeem callout above is the fix in that case). - update the BillingCard JSDoc the restructure left stale (referenced the deleted RouterWalletSection / Model costs card). * docs(billing-v2): refresh screenshots — honest optional-payment-method UI * polish(billing-v2): final UI pass + copy-options doc UI (from a 3-lens design fan-out, applied wins): - tighter card rhythm (title Divider h-5 → h-3). - level the status-pill tints (good/bad bg -50 → -100) so all four badges carry consistent weight; off-palette history dot bg-yellow-400 → bg-amber-400. - lowercase the date asides (renews/ends) to match the voice + the other asides. - demote the Redeem button to a quiet text-link — the only filled CTA on the surface is now the primary 'Start · $30/mo'. - Billing history: progressive disclosure — collapse to 4 recent rows with a 'Show all (N)' expand instead of always rendering 10. copy: adds .plans/billing-model-v2/copy-options.md — 108 user-facing strings with a recommended best version + alternatives + rationale (3-voice fan-out + editor synthesis) for later human edit. * docs(billing-v2): refresh screenshots after the minimal/clean polish pass * polish(billing-v2): declutter the auto-reload panel + unify history chips - auto-reload expanded panel: drop the hr rules between Amount/Threshold/limit/ behavior, single spacers instead — the disclosed config reads as one grouped block, not stacked sections. - billing-history failed/pending chips reuse the shared TEAM_BADGE_TONE pill spec (one pill treatment across the whole surface). * polish(billing-v2): one filled CTA on the whole surface (the subscribe conversion) verified the with-card states for the first time (wired a Stripe test card) and caught the inconsistency: Top up + Manage plan/Update card were filled buttons while Add card/Redeem/Edit/Update were quiet links. Now the ONLY filled button anywhere is 'Start · $30/mo' (the conversion); every management action (Manage plan, Update card, Top up, Redeem, Edit, Add card, Update) is a quiet evergreen link. Loud only when we want the user to subscribe; quiet management otherwise. * docs(billing-v2): add with-card + auto-reload screenshots (verified via test card) * fix(billing-v2): revert TEMP god mode + guard the subscription writer against stale-event clobber pre-merge correctness review (6 logic lenses × adversarial verify) surfaced two real defects; the rest of the money path verified sound. 1. CRITICAL — revert the 'TEMP: froggerton god mode' grant. froggerton is a SHARED test account and god mode short-circuits getOrgMembership, so merging it would hand froggerton billing-admin (card, subscription, balance read + card-detach + subscribe) over EVERY org. GOD_MODE_USERS back to colinhacks + ssalbdivad. 2. MAJOR (dormant behind the gate) — syncSubscriptionFromStripe upserts on (accountId, product), so a late/redelivered event for a superseded Stripe subscription (an org that cancelled then re-subscribed has two distinct sub ids) clobbers the row now bound to the LIVE sub — demoting a paying org back to 'paused' → wrongful 402s. Add a stale-event guard: when the incoming event targets a different sub id than we track, adopt it only if strictly newer (sub.created vs the bound sub's, re-retrieved). Documented the invariant in wiki/billing-model-v2.md. * fix(billing-v2): 2 adversarial-review defects — finish the per-run rip-out + harden the stale-event guard adversarial strategy+impl review (5 lenses + synthesis) surfaced two verified code defects, both behind the dormant kill switch: 1. the reconcile-invoices cron still hunted runs_monthly invoices and re-armed auto_advance=true, so the ~$195.79 of already-finalized legacy per-run invoices we decided to WAIVE would keep getting dunned post-launch. removed the runs_monthly reconcile branch entirely (dead-forward: v2 never creates another runs_monthly invoice) — completes the standup 'rip out ALL old per-run logic' and closes the dunning contradiction. reconcile now handles only Router top-ups. 2. the stale-event guard (added this session) caught the bound-sub re-retrieve to null, which on a TRANSIENT stripe error fell through to clobber a live paying row and rebind it to a dead sub (paying org refused up to a month). removed the catch so a transient error throws → webhook 500 → stripe redelivers when healthy, matching handleSubscriptionEvent's no-catch pattern. free-tier-abuse lens confirmed the two-gate architecture leaks no covered private review (stale mirror fully backstopped by run-context's live read); lifecycle lens confirmed wind-down cron, past_due gracious dunning, and billableCents=0 all correct. wiki: updated the rip-out inventory + launch items 8/10 + the stale-event-guard invariant; augmented the org-farming bounded-leak with the $10-credit dimension; copy-options gets an honest-framing note for the trial email. * fix(billing-v2): 2 more adversarial-review fixes — authz stale-cache on the new subscribe gate + UTC date render authz lens confirmed the money endpoints are fundamentally sound (cross-org blocked at the getAuthenticatedAccountContext chokepoint, owner-only enforced, DTO leaks nothing to non-owners, proxy-token OIDC-scoped). one real gap on a gate THIS pr adds: 1. team-subscribe/[owner] is a new money-mutating gate (mints a $30/mo Checkout) that skipped the invalidate-then-read the repo requires at critical org-role gates — it trusted an up-to-4h-stale membership cache, so a just-demoted ex-owner could still mint a subscription for their org. added the getUserOrgMemberships.invalidate(...) call before the isAdmin gate, matching /enable-billing. (the pre-existing read/portal gates are unrelated code this pr didn't touch; the read path deliberately keeps the cache to avoid hammering GitHub.) 2. BillingCard teamShortDate rendered a midnight-UTC subscription boundary via toLocaleDateString without timeZone:'UTC', so a viewer west of UTC saw 'renews/ends Jul 31' for an Aug-1-UTC boundary — off by one day on customer-facing billing dates. pinned to UTC. (teamDaysUntil was already tz-safe.) BillingCard state machine otherwise verified sound: real satisfies-never exhaustiveness over all 6 statuses + untried, no blank/double-CTA render, signup-credit suppression correct, hasCardOnFile consistent. * fix(billing-v2): make silent billing failures loud + label the fail-open backfill operational-readiness lens: billing-v2 machinery is diligently logged but thinly ALERTED — most signals sit below the error-level log-audit cron, so a prod billing failure would surface only when a customer complains. brought two paths up to the repo's emailAlert standard (the one wind-down email already follows): 1. checkStripeWebhookLiveness — the sole monitor for the subscription writer, whose own workflow comment calls it 'the alert we rely on', only emitted a log.warn (invisible to the level='error' log-audit). now emailAlerts billing@ on stall (Resend 24h idempotency throttles the ~4/hour passes to <=1/day). corrected the stale 'log-only' JSDoc. 2. handleSubscriptionEvent — a subscription-writer throw (Stripe schema drift, DB error) silently froze the paid state machine and surfaced only via the slow log-audit. now emailAlerts before rethrowing (rethrow preserves Stripe's redelivery; key dedups the retry storm). schema-migration lens (otherwise very clean — zero drift, safe enum handling, indexed hot path, inert onDelete:Restrict): backfill-account-types.ts is as fail-open-critical as backfill-repo-visibility.ts but lacked the sibling's 'HARD launch prerequisite' banner. added it. wiki launch checklist gains an observability item documenting the heavier pre-arm work (cron heartbeats + a subscription-reconcile/audit step + arming-day event-type verification) that needs a migration + a new reconcile step. * fix(billing-v2): revert unfounded UTC date change, env-scope stall alert key, finish the unified-card docs round-5 self-review + coherence lenses caught issues in this session's own work + a half-finished doc narrative: 1. REVERT the teamShortDate UTC change (c795be10). its premise was false: trialEndsAt/graceEndsAt are now+N-days (arbitrary time-of-day), NOT midnight-UTC, so there was no clean off-by-one to fix. and BillingCard is 'use client' fetching via react-query (skeleton during SSR), so the dates render client-side only — no hydration mismatch to fix either. forcing UTC gave no benefit and was slightly less locally-faithful. back to the original local render. 2. env-scope the stripe-webhook-stalled idempotencyKey. Resend dedups on the key alone (the [preview]/[local] subject tag doesn't count), so a bare literal would let a preview-env stall swallow a real prod alert for 24h — the exact silent-swallow the alert exists to prevent. keyed by VERCEL_ENV now. (sub-sync-fail-<id> is already env-unique via the sub id.) 3. doc-with-code: the PR merged the old 'Model costs' card into the unified Billing card (Model usage section), but 4 docs pages still sent users to hunt for the removed card. updated docs/billing.mdx, docs/keys.mdx, docs/getting-started.mdx, docs/organizations.mdx to the shipped 'Billing card → Model usage section' labels. 4. wiki: the $195.79 waive was decided at standup (the customer email states it as settled), so reframed launch item 8 from an open 'confirm waive-vs-settle with Colin' question to 'decided: waive; final go-ahead before the irreversible void' — reconciling the doc-vs-doc split the coherence lens flagged. self-review verified the other 5 session fixes sound (rip-out complete + dangling-free, no-catch guard safe in every caller, team-subscribe invalidate correct, emailAlerts non-spammy + redelivery-preserving). coherence lens verified dormancy airtight, opt-in/no-auto-charge holds end to end, and 'removes per-run billing entirely' actually holds. * fix(billing-v2): 6 hardening fixes from 5 adversarial (Colin-grade) reviews the 5 reviews (money / type-safety / arming / concurrency / fire-me-bug-hunt) verified the core claims sound — no org is ever auto-charged (only sub-mode Checkout mints a Stripe sub), dormancy is airtight, money math + idempotency + refunds are correct — and surfaced these fixes: SERIOUS: 1. double-subscribe double-charge (flagged independently by money + concurrency): two completed Checkouts mint two live Stripe subs = $60/mo, and the (accountId,product) row only tracks one. syncSubscriptionFromStripe now picks a deterministic winner (created, sub-id tie-break) and cancels the loser if still billing + emailAlerts ops — converges to one sub. also fixes the same-second flap edge. 2. wind-down cron cold-emailed churned orgs (fire-me): advanceOneSubscription now checks getOwnerInstallation and skips uninstalled orgs (kills the wiki's forbidden follow-up-TODO). 3. prod-scope arming loaded-gun (arming): isCommercialGateEnabled gates the env override to VERCEL_ENV!==production so a mis-scoped prod env var can't silently arm prod. HARDENING (worth-raising): 4. metadata.product allow-list — unrecognized values skip instead of silently writing a team row. 5. satisfies-exhaustiveness at both run-gate consumers so a new TeamRunVerdict kind can't silently reach an allowed run. 6. added the Repo.private backfill to the subscriptions.ts prereq JSDoc (was drifting from the wiki checklist). * docs(billing-v2): opt-in transition copy + wiki reconciliation the transition narrative had drifted to 'first $30 lands Aug 1' auto-charge framing (from the standup), contradicting the opt-in code + the ARL/ROSCA legal requirement + the original plan.md/RESOLVED-SPEC §4 lineage (both always opt-in). reconciled: - customer-email.md: simplified to plain factual pricing (dropped the 'we will never' pledge block + all forward-looking commitments per David); transition note now states the waiver + rest-of-month-free-bonus + opt-in ('nothing auto-charges, it just pauses, no charge, ever'). - subscription-seed.ts: 'convert on the 1st' → opt-in reality (rolls trialing→grace→paused unless it opts in via Checkout). - wiki: launch step 5 opt-in + waiver-before-Aug-1; items 6 + 10 marked done-in-PR (wind-down install gate, double-subscribe fix); stale-event-guard invariant + emergency-disarm caveat + terms §4.2 negative-option flag updated; org-farming $10-credit dimension. * harden(billing-v2): loud alert on failed duplicate-sub cancel + Colin review agenda a 7th adversarial pass VERIFIED the 6 prior fixes safe (the cancel-live-sub logic never cancels a wanted sub — only fires on two distinct Team subs binding one row — converges deterministically, no loop, product-isolated). one worth-raising residual acted on: - subscriptionStripe.ts: on a duplicate-sub cancel FAILURE the customer is double-billing right now and the old path only log.error'd (slow log-audit). now emailAlerts billing@ URGENT so ops cancels manually fast. still doesn't rethrow (a persistent cancel failure would poison the event + strand the row) — winner adoption stays correct. - .plans/billing-model-v2/colin-review.md: the top-10 implementation-review agenda for the Colin conversation (verified-sound summary + the 10 decisions/questions). - wiki: sharpened the proxy-token pre-arm hardening note — it must mirror the run gates' public-repo exemption (dbRepo lacks Repo.private) + needs armed-path e2e, so it's built in the arming PR, not blind. * feat(billing-v2): low-usage trial-extension nudge — another free month for barely-used trials design (2026-07-11): a free-model floor (fall back to a keyless $0 model instead of a wall) was considered and REJECTED — a flaky free model reflects on Pullfrog's quality. instead keep the $10-credit-then-wall (the wall is the ENGAGED user's conversion moment, 49% attach at exhaustion) and re-engage the DISENGAGED tail: a trial ending with <= TRIAL_EXTENSION_RUN_THRESHOLD (10) WorkflowRuns over its window, not yet extended, gets ONE more free month instead of winding down (trialEndsAt = +30d, stays trialing) + a 'another month on us' email (emails/trialExtension.ts). attacks activation (70% of installs never run), not conversion. capped to once via the new Subscription.trialExtendedAt, claimed with the same status-guarded updateMany as the wind-down (a concurrent conversion → count 0 → no extend, no email). dormant like the rest (only fires when armed). the two email sends now share one guarded sendLifecycleEmail. schema: Subscription.trialExtendedAt (additive migration). wiki: state machine + data model + files documented. * polish(billing-v2): ruthless concision pass across all PR-introduced customer copy tightened every customer-facing string this PR introduces — em dashes → colons/periods (no em dashes), 'no per-run caps' → 'no per-run fees', 'has wound down' → 'ended', 'didn't go through' → 'failed', cut filler + redundancy ~40%, killed fragments: - announcement email (customer-email.md): simpler bullets, 'Larger orgs:' replaces vague 'Scale and controls', fixed the 'core agent' fragment, tightened the transition note (opt-in framing preserved). - paywall PR comments (billingErrors.ts + triggerWorkflow.ts, kept consistent). - trial wind-down email. - BillingCard UI (LockedCard + header/auto-reload tooltips; 'reviews across' → 'on'). - homepage pricing subheading + docs/billing.mdx ($10 tip, redundancy). all facts preserved: opt-in (paused, not auto-charged), no 'unlimited'/'never' pledges, $30 flat, $10 credit, tokens at cost, public+personal free, org-owner gate. * harden(billing-v2): armstrong-round fixes — CAS subscription writer, migration squash, copy accuracy - subscriptionStripe.ts: replace the unconditional upsert with a CAS convergence loop so a straggler webhook for an older sub can't demote a paying org to paused (the read+write weren't atomic; the same-id path had no compare-and-swap). Independently adversarially verified against Postgres READ COMMITTED (EvalPlanQual). - migrations: squash the 3 branch migrations into one 20260708130000_billing_model_v2 (drift-verified empty; prod has none applied). - stripe.ts: correct the 'Never a charge now' trial_end comment (Stripe bills on checkout completion when the free window is already gone; no documented minimum). - windDown.ts: trial-extension counts LIFETIME runs (a transition org's pre-arm usage was hidden by the sub.createdAt floor, mailing a false 'barely used' nudge); honest JSDoc on the crash-gap; drop the now-dead createdAt select. - copy: kill em/en dashes in send copy (announcement, paywall PR comment, homepage); fix docs button-label drift (Enable billing -> Add card, Update billing -> Update). - wiki: document the CAS write as the genuine event-ordering analogue. * polish(billing-v2): copy-review pass — reviews→runs, unified paywall copy, enterprise coming-soon, de-em-dash - broaden 'reviews' → 'runs' across the wind-down email, both paywall surfaces, and the Team-plan card (the gate refuses ALL run types on private repos, not just reviews). - unify the two paywall comment surfaces into one shared commercialPaywallBody (action/utils/billingErrors.ts, imported via pullfrog/internal) so the trigger-time and mid-run 402 copy can never drift; trigger comment now the more prominent multi-line form. - warmer trial-extension email; nudge counts LIFETIME runs (a transition org's pre-arm usage was hidden by the sub.createdAt floor → false 'barely used'). - enterprise tier labeled 'coming soon' + contact us (no unbuilt SOC2/HIPAA/SSO claims stated as present); mirror in docs; drop bare SSO from the email. - trial_end deferred-charge comment accuracy; docs button labels (Add card / Update). - strip em dashes from every this-PR-authored file + all customer-facing copy. * docs(billing-v2): homepage pricing screenshot (flat $30 tiers + enterprise coming-soon) * polish(billing-v2): complete Pro rollout hardening and UI * finish billing v2 launch flow * polish finalizing plan state * refine Pro trial and billing card * fix pricing intro spacing * move finalizing detail into tooltip * hide internal subscription reconciliation state * clarify free and Pro plan presentation * move model usage note below Pro action * clarify post-trial Pro upsell state * highlight lapsed Pro states * strengthen lapsed billing states * polish billing plan states * fix(models): discriminate openai-compatible on the resolved specifier decideModelAccess keyed the openai-compatible carve-out off the catalog alias (resolveDisplayAlias(input.model)?.routing), while validateAgentApiKey keys off the resolved specifier prefix. the two gates therefore disagreed about a raw `openai-compatible/<id>` specifier: apiKeys admits it, but modelAccess left byokAuthorized false and would hard-fail the run with "requested model is not available". not reachable today — `--model=<slug>` only parses catalog slugs (utils/flags.ts parseReservedToken), so modelExplicit can't currently pair with a raw specifier. latent until the flag surface loosens or another caller passes one. both forms resolve to the same `openai-compatible/` prefix, so checking the resolved specifier covers each and drops the resolveDisplayAlias call. found by Cursor Bugbot on #983. * fix(models): let openai-compatible declare token limits opencode has no models.dev metadata for a user-supplied endpoint, so with no `limit` in the injected provider block it sends max_tokens: 32000. Any model with a smaller completion cap rejects the request outright: litellm.BadRequestError: OpenAIException - max_tokens is too large: 32000. This model supports at most 16384 completion tokens GPT-4o/4o-mini cap at 16384 and many open models at 4096/8192, so this is the common case, not the edge — the feature was effectively broken for most real endpoints. adds optional OPENAI_COMPATIBLE_CONTEXT + OPENAI_COMPATIBLE_MAX_OUTPUT, emitted as limit:{context,output} (opencode's schema requires both). no default is invented: the endpoint owns the catalog, so guessing would silently cap capable models. undefined is dropped by JSON.stringify, which restores opencode's own default. found by the real-LiteLLM preview e2e. * feat: prepare August billing transition * address review: compaction consequence, partial-limit warning, scratch file, base-URL label four findings from the bot reviews on 76dd32c5, each verified against opencode v1.16.2 source before applying: 1. an absent `limit` defaults to {context:0, output:0}, and that zero is load-bearing TWICE. maxOutputTokens is Math.min(limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX (transform.ts:1241) — which is where the observed max_tokens: 32000 actually comes from — and isOverflow short-circuits on limit.context === 0 (overflow.ts:31), silently disabling auto-compaction for the whole run. that second effect is the quieter and worse one: no error until the session is already too big to send. so the guidance is now "declare both to match your model", not "set them if you hit the 32k error". 2. a partial or unparseable declaration was indistinguishable from unset — the user got back the exact rejection these vars prevent with nothing in the log. now warns naming both consequences. 3. gen-cfg.mjs: a local e2e scratch helper swept in by `git add -A`. removed. biome does not process root-level .mjs, so lint stayed green with it present — no automated gate would have caught this. 4. buildOpenAICompatibleSetupError told users to store the base URL as `secrets.OPENAI_COMPATIBLE_BASE_URL`, contradicting the docs page and the console callout, which both state only the key is sensitive. also surfaces both limit vars in the generated workflow template, which was the only place the optional pair was undiscoverable. * fix: isolate Claude process secrets * Revert "fix: isolate Claude process secrets" This reverts commit 8b556046f6761cc2ada78022e883c4cad8f3f47b. * feat(models): require the openai-compatible token limits maintainer call: fail fast rather than warn. the compaction failure is invisible until the session is already too big to send, so a warning in a log nobody reads is not enough — and opencode's schema requires context + output together, so a partial declaration would silently do nothing. validateOpenAICompatibleSetup now requires both as POSITIVE NUMBERS, not just present, so `128k` is rejected at the gate instead of being silently discarded downstream. that makes the values guaranteed by the time buildSecurityConfig runs, so openAICompatibleLimit() drops its warning branch and returns a plain ModelLimit. verified the one path that skips validation is safe: it is skipped only when payload.proxyModel is set, and in that case rawModel becomes the proxy target, so openAICompatibleProvider short-circuits and the limit function is never reached. deliberately NOT added to the provider's `envVars`: that list feeds hasPullfrogStoredAuthForModel on the server, which is a `.some()` over AUTH vars — a stored context number would falsely satisfy the Router opt-out. the limits are config, not credentials. propagates 'required' through the setup error (now explaining what the numbers are for), the console callout in both states, the docs table + both yaml examples, the workflow template, and the wiki. * docs: correct the partial-limit claim and the stale three-var contract two doc-accuracy findings from the pullfrog review on 150e1978. the wiki said a partial `limit` "would silently do nothing" upstream. that is wrong, and verified so at both v1.16.2 and v1.18.5: OPENCODE_CONFIG_CONTENT decodes through ConfigParse.schema, which throws InvalidError on any decode failure (config/parse.ts:59), and loadInstanceState is Effect.orDie (config/config.ts:596) — a `limit` missing `context` is a FATAL config-load error, not a degraded run. that makes required-both better justified than what was written, and the wiki is the file future maintainers trust as the upstream anchor, so the wrong rationale could invite relaxing the gate later. also brings six surfaces up to the five-var contract that 150e1978 established but did not propagate: both env-var JSDoc blocks in models.ts (which still described the limits as optional-with-a-consequence), the docs/models.mdx keys table, docs/keys.mdx, the wiki's "three env vars" line and provider env summary, and the isOpenAICompatibleSlug JSDoc. * docs(wiki): cross-ref the strict-schema failure class, pin the v1.18.5 anchor the merge brings opencode-ai 1.18.5. re-verified all four upstream anchors myself at the v1.18.5 tag rather than assuming they carried: - limit is Schema.Struct({context: Schema.Finite, output: Schema.Finite}) — both non-optional, and Finite also rejects NaN - OUTPUT_TOKEN_MAX = 32_000 with the Math.min(...) || fold-back (transform.ts:1396) - isOverflow's limit.context === 0 short-circuit (overflow.ts:29) - @ai-sdk/openai-compatible still bundled (provider.ts:117) links the partial-limit note to wiki/opencode-version-skew.md, which main just added and which documents the same strict-config-schema-is-fatal dynamic. * fix: address billing review findings * fix: align expired trial billing copy * fix: harden billing transition gates * fix: separate consent audits from webhook retries * fix(action): seal agent hook and shell boundaries * Revert "fix(action): seal agent hook and shell boundaries" This reverts commit e2490480dca88efde22f4f48e82d4ebca0a80d5c. * fix: preserve grace during incomplete checkout * fix: show paid plan during consent sync * fix: defer consent for pending checkout * feat(models): group the openai-compatible entry under a "Custom" picker heading the picker rendered `OpenAI-compatible › OpenAI-compatible` — the provider group label and its single entry were the same string, which reads like a duplicate rather than a hierarchy. naming the group "Custom" gives `Custom › OpenAI-compatible` and leaves room for a second custom backend (a different wire format) to slot in beside it without renaming anything. display-only: the provider KEY stays `openai-compatible`, so the stored slug `openai-compatible/byok` and the OPENAI_COMPATIBLE_* env prefix are unchanged. no migration, no stored-value rewrite. also updates the two docs surfaces that named the old picker path — the "Select ... in the model dropdown" step and heading in docs/openai-compatible.mdx, and the keys-table row in docs/models.mdx. * fix(action): restore sealed agent boundaries * polish billing transition notice * Revert "fix(action): restore sealed agent boundaries" This reverts commit a2f6bd2d6e7f5c06e278831f0c5350c4a3f80271. * fix transition grace notices * fix billing date timezone * fix final billing lifecycle review findings * polish billing action notice * feat(console): unified model dropdown + Custom endpoint onboarding replaces the full-width Auto|Choose-model segmented control with a single dropdown whose first row IS Auto. the toggle split one choice across two widgets, and it had to force its own Auto tab whenever the Router clamp applied — so the pressed tab could disagree with what repo.model held. ModelSelector gains three props: - autoOption — renders the Auto row. the CALLER owns what Auto means, because it differs by mode: Router writes an auto/* sentinel, BYOK writes null. writing null on Router would mean "inherit the org's concrete default", the opposite of Auto, whenever account.defaultModel is set. the component deliberately does not assume Auto === null. - concreteLocked — disables every concrete row for the wholesale Router clamp (no payment method), desktop submenus and mobile accordion alike. - lockedFooter — the unlock CTA, previously a tooltip on an inert tab. isLocked becomes lockReason returning "card" | "byok" | null, because the two locks have different remedies and so need different row notes; a boolean forced them to share "BYOK only", which is the wrong advice for a card lock. when the clamp applies the trigger now shows Auto — what actually runs — and the stored pick is disclosed underneath rather than displayed as selected. onboarding: adds openai-compatible to BYOK_PROVIDER_ORDER so it gets a provider card, with a five-field group (key as password, base URL / model / context / max-output as text). saving all five means never editing pullfrog.yml — they arrive as dbSecrets and are injected at run time. kept out of ONBOARDING_PROVIDER_ORDER alongside bedrock/vertex: a several-value backend is the wrong ask on step one of first-run. deliberately still NOT in the provider's envVars — that list feeds the server's hasPullfrogStoredAuthForModel, a .some() over AUTH vars, where a stored context number would falsely satisfy the Router opt-out. wiki/model-resolution.md documents the three-axis gating table, both locks, and the two traps. * Hard…Configuration menu - View commit details
-
Copy full SHA for e8abdce - Browse repository at this point
Copy the full SHA e8abdceView commit details -
feat: user-configurable reasoning effort (#1108)
* feat(effort): add reasoning-effort scale + per-model ladders to the registry every provider's effort ladder is a subset of one ordered scale (none < minimal < low < medium < high < xhigh < max) — OpenRouter's unified scale and opencode's internal enum, a superset of claude-code's --effort. adopt it rather than invent a vocabulary. per-model rungs mirror models.dev reasoning_options and were generated from the catalog, not hand-transcribed. deepseek/glm diverge between the direct and OpenRouter routes (max vs xhigh), so effort gets the same direct/router split resolve already has. * feat(effort): add Repo.effort column, migration, and shared export surface * feat(effort): thread effort through the payload boundary (action.yml input, JsonPayload, Inputs, RepoSettings) * feat(effort): assert registry effort ladders mirror models.dev, carry them through the bump cron the catalog gate is not cosmetic: claude-code hard-errors on an out-of-range --effort before any API call, so a stale ladder breaks runs. the models-bump cron edits resolve values, and a bump can change the ladder — so find-newer-models.ts now surfaces the candidate's ladder and the bump prompt requires moving effort/openRouterEffort with it, or the bot's PR would sit permanently red against the new gate. * feat(effort): wire effort into both harnesses, the console, flags, and docs claude-code takes --effort, opencode takes --variant; each owns the per-model per-transport translation upstream, so Pullfrog passes one string. deletes deepseekHighEffortOverrides + geminiHighThinkingOverrides, which this subsumes. gates every user-facing surface on SELECTABLE_EFFORTS rather than the full scale — claude-code exits non-zero on none/minimal before it makes any API call, and the action input, repo setting and flag all reach the runtime without passing the flag parser. tightening WriteablePayload.effort to SelectableEffort surfaced four more dispatch sites with the same hole. resolves the ladder from the model that actually executes (proxyModel ?? resolvedModel), not the configured slug — an OSS run keeps the configured model in settings.model while forcing DeepSeek, so the old path clamped against a model it wasn't using and opencode silently dropped the result. * fix(effort): forward the rung to unrecognized models, cap subsidised runs both opencode harnesses fall through to autoSelectModel(), which picks after we resolve — so an unrecognized model is usually an auto-selected one. dropping the effort there silently regressed auto-selected Gemini off the thinkingLevel:high the deleted static override used to pin. forward it instead: every offered rung is one claude-code accepts on any model, and opencode ignores a variant its model doesn't publish. OSS runs are Pullfrog's spend, so cap them at the default — a --effort=max in any triggering comment otherwise raised the reasoning budget on a subsidised run. capped in main.ts rather than run-context because the flag arrives on the payload and outranks the repo setting. * fix(effort): resolve the ladder from the auto-selected model, not a guess 78f2bd8b forwarded the requested rung whenever no alias resolved, on the theory that opencode ignores a variant its model doesn't publish. that is false: providers-live drives PULLFROG_MODEL=google/gemini-pro (a slug, not a resolvable specifier), and the resulting --variant hung the run until the job timed out — twice. the real fix is to stop guessing. both opencode harnesses already compute rawModel = proxyModel ?? resolvedModel ?? autoSelectModel() before they need the flag, so pass that in: a genuinely auto-selected run now resolves its ladder and gets a clamped variant, which is what the deleted gemini override used to cover, while an unidentifiable model gets no flag. * fix(effort): stop the startup log blaming the model for our own timing the block prints before the agent starts, so an auto-select run has no model yet — it reported 'model has no effort control' about a Claude Opus pick with a full ladder. say 'pending' there, and have opencode log the real level once rawModel is known, which is the first point it is. * refactor(effort): reuse the shared Tabs control, sized to match the model picker the segmented bar was 678px against the model dropdown's 240px, and used a label-above/full-bleed layout where the model row is label-left/control- right — two patterns in one card, with the secondary control shouting louder than the primary one. reuses components/ui/tabs.tsx as a segmented value selector, the same way ModelAccessCard does for router/byok, so the selected and disabled states come from the shared component instead of hand-rolled classes. lands at 291px on the model row. * fix(effort): address review — dead-harness log, over-broad OSS pin, doc drift seven inline findings, each verified before applying: - the new startup-effort log landed in opencode.ts, which nothing imports (index.ts takes the harness from opencode_v2.ts). mirrored into v2, where it actually runs. - the OSS clamp was a pin, not a cap: --effort=low was rewritten UP to high, spending more of our money than honoring it. now only lowers a rung above the default, and only when the subsidy proxy is actually in play — an OSS repo on its own stored key is the customer's spend. - the startup log said 'pending — model not chosen yet' for a raw specifier, a PULLFROG_MODEL override and hosted backend IDs, where the model is definitively chosen. narrowed to the auto-select arm and split the three causes apart. - wiki named ANTHROPIC_DEFAULT_OPUS_MODEL; the code deliberately uses CUSTOM_MODEL_OPTION because DEFAULT_OPUS also redefines 'opus' for the session. - wiki said opencode silently no-ops an unknown variant while runEffort said it hangs. both are true of different cases; say which is which. - stale RunEffort.effective JSDoc from the pass-through revert. * refactor(agents): delete the unused OpenCode harness, rename the real one action/agents/index.ts imported the harness from opencode_v2.ts; the CLI-subprocess opencode.ts sat beside it imported by nothing. that is exactly how a fix lands in the file that doesn't run — this branch put a startup-effort log in the dead module and it would have shipped silent. drops the dead module and renames the live one to opencode.ts. the header now records that 'v2' meant the second generation of OUR harness, never a version of OpenCode itself, since that ambiguity is what made the stale file look load-bearing. test coverage globs collapse accordingly. * feat(effort): carry the setting across model changes by index, drop the hint each model's ladder is its own range, so mapping by NAME doesn't survive the general case: high is DeepSeek's floor and Opus's middle, and a name-preserving map turns 'cheapest' into 'mid-range'. position does survive it — index 0 is that model's cheapest whatever it is called. so the control renders the selected model's own rungs, and switching models carries the setting by index, capped at the target's top. the disclosure hint goes away because the stored value is now always one the model actually offers. a model with no ladder leaves the value untouched, so it applies again once a capable model is picked. verified in the console: Opus/xhigh (index 3 of 5) -> Gemini Pro -> 3 rungs with High selected, persisted as effort=high. * feat(effort): proportional remap, no-ladder label, and a soundness proof remap by RELATIVE position along each ladder rather than raw index: the top of a 3-rung model should land on the top of a 5-rung one, not partway up it. position = index/(len-1), landed at the same fraction of the target, ties rounding down so an inexact landing never costs more than asked. equal-length ladders map one-to-one. a model with no ladder now shows a label instead of a dead disabled control — disabled tabs imply something you could switch on. SOUNDNESS: the stored value is never trustworthy. it survives model changes made through the org default, the Router card clamp, auto/* tier resolution, a --model= flag, a direct DB write, and a models.dev ladder change underneath it. so the runtime guarantees validity independently of the console: resolveModelEffort now clamps against the OFFERED rungs, not the raw published ladder, which several models publish none/minimal in. that makes the emitted set exactly claude-code's accepted set, so an out-of-range --effort — exit 1 before any API call — is impossible by construction rather than by argument. action/effort.test.ts proves it exhaustively: every runnable alias x every rung on the whole scale x both funding routes, plus every model-to-model remap pair. 2986 assertions. verified the test can fail — reverting the offeredEfforts clamp turns it red with 72 failures, all 'to include none' and 'to include minimal'. * fix(effort): the model's ladder is the ordering authority, not our vocabulary a rung name is now just a string. models publish whatever words their vendor uses, and order comes from the published array — already ascending — so nothing needs to know what 'xhigh' means. remapping across a model change was already position-based and works unchanged on made-up names. fixes a silent wrong value: clampEffort ran indexOf against the known vocabulary, and -1 is nearest to index 0, so ANY unrecognized level resolved to the model's cheapest rung. clamp('ultrathink', [low,medium, high]) returned 'low'. it now exact-matches first and returns nothing when a name can't be honestly placed. adds an egress guard at the claude-code boundary, since models.dev and the CLI's enum are different sources and free to drift: a rung the pinned binary won't parse is dropped with a warning instead of exiting the run. verified by probe that --effort ultra EXITS 1 despite 'ultra' appearing in the binary's own strings — reading the strings suggests it is accepted and it is not. * refactor(effort): store effort as a position on [0,1], not a rung name a rung name is only meaningful inside the model that published it — `high` is DeepSeek's floor and Opus's midpoint — so a stored name is untransferable the moment the model changes underneath the setting, which happens without anyone editing it (org default cascade, Router card clamp, auto/* resolution, per-run --model, and the models-bump cron rewriting a ladder). storing a position deletes the clamp (and its silent-floor bug), the remap-on-model-change and its UI rewrite, the fixed vocabulary as an ordering authority, and the whole 'stored value went stale' class. resolution is a lookup into the model's own published ladder, so an invalid effort is unrepresentable rather than guarded against, and rung names may be arbitrary strings the way models.dev actually publishes them. default becomes unset (send no flag, model applies its own) — provably zero change for a repo that never touches the setting. Repo.effort is now Float?. the OSS cap rides the same axis (position 0 = the subsidy model's own floor); CLAUDE_CODE_EFFORT_LEVEL can't serve it, being claude-code-only while subsidised runs go through opencode. * docs(effort): cite the variant-hang differential rather than asserting it review asked for a citable repro, correctly: a source trace of opencode 1.18.5 says a variant cannot block, and that trace is right for a model opencode can PLACE. the distinguishing evidence is a differential — the unplaceable path hung twice at 78f2bd8b where 3c6beda2 was green, while a real direct-Google specifier takes --variant high and succeeds. * feat(effort): default position 0.75, and always round down resolution was nearest-rung-with-ties-down; it is now a strict floor, so a position between two rungs never rounds up. the only divergence across the real registry is the two-rung DeepSeek/GLM family, and it is the one that matters: at the default, nearest would have given max where floor gives high — what every DeepSeek run got before this setting existed. rounding down makes the round-trip load-bearing (a rung's own position must floor back to itself, or picking a level in the console would store the one beneath it). k/(n-1) x (n-1) is exact in IEEE754 for every ladder size we have, checked exhaustively to n=12 and pinned by a test over every registry ladder, so no epsilon fudge. an unconfigured repo now resolves at 0.75 instead of sending no flag, so the separate Default tab is gone — the default is a real position landing on a real rung, and a Default tab would double-select against it. * docs(effort): fix the claims the 0.75 default made false review caught three, all fallout from the default change: - docs/flags.mdx still promised "the default is high — the level every model already ran at, so nothing changes until you ask it to". user-facing, and now false in both halves. - wiki/effort.md listed "nothing configured" as a distinct startup-log message; that branch is now a (default) suffix on the rung. - the AGENTS.md wiki index still described the name-based design (none→max scale, resolve-time clamping, ties-downward). also drops `value={selectedRung ?? ""}` — the fallback is unreachable (resolveRung only returns undefined for an empty ladder, which the branch above already handles) and typechecks without it.Configuration menu - View commit details
-
Copy full SHA for 4d49071 - Browse repository at this point
Copy the full SHA 4d49071View commit details -
release: bump action to 0.1.48
ships user-configurable reasoning effort (#1108) to the published action: per-repo setting stored as a position on [0,1], --effort=<level> flag, and an effort action input, resolved against the running model's own published ladder.
Configuration menu - View commit details
-
Copy full SHA for 4fc17b9 - Browse repository at this point
Copy the full SHA 4fc17b9View commit details
Loading
This comparison is taking too long to generate.
Unfortunately it looks like we can’t render this comparison for you right now. It might be too big, or there might be something weird with your repository.
You can try running this command locally to see the comparison on your machine:
git diff v0.1.47...v0.1.48