Skip to content
Permalink

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
Choose a base ref
...
head repository: pullfrog/pullfrog
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v0.1.48
Choose a head ref
  • 3 commits
  • 37 files changed
  • 2 contributors

Commits on Aug 2, 2026

  1. 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…
    ssalbdivad authored and pullfrog[bot] committed Aug 2, 2026
    Configuration menu
    Copy the full SHA
    e8abdce View commit details
    Browse the repository at this point in the history
  2. 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.
    colinhacks authored and pullfrog[bot] committed Aug 2, 2026
    Configuration menu
    Copy the full SHA
    4d49071 View commit details
    Browse the repository at this point in the history
  3. 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.
    colinhacks authored and pullfrog[bot] committed Aug 2, 2026
    Configuration menu
    Copy the full SHA
    4fc17b9 View commit details
    Browse the repository at this point in the history
Loading