2.5.0
Released
Features
Finishing a quest's last task completes the quest, on every quest rather than only Party Quests. The task section asks the server for fresh data the moment it can tell the quest must have closed, so the header, the lifecycle buttons and the frozen-tasks notice update without a navigation (#477).
Marking a quest complete with work outstanding asks what to do with it — "7 tasks still to do", with Mark all done and Complete anyway. A quest whose tasks are already done, or which has no tasks at all, completes on the first press as before (#477).
A quest with a fixed task list hides the add-a-task form instead of showing an empty one with a dead Add button. The reason moves to a one-line notice beside the other task locks (#477).
A Random Quest Challenge now actually constrains its quest. Its checklist arrives locked: the tasks can be completed, reordered, assigned and pinned to a place, but not renamed, rewritten or removed, and nothing can be added alongside them. Previously the checklist was copied into an ordinary quest and could be emptied or replaced wholesale, and completing whatever was left still counted as completing the challenge. Locked tasks are marked "From the challenge" and lose their Edit and Delete buttons; the add-a-task form explains why it is closed (#477).
A challenge card names the reward the product actually grants — the Challenge Runner achievement — instead of an XP bonus that nothing anywhere awarded, read or displayed a total for. The same wording went from the dashboard strip, the quest banner, the challenge history and the marketing hero (#477).
An investigation comes back as one short paragraph of plain prose. It used to ask for four markdown sections — Summary, Findings, Risks, Next steps — of which only the first line was ever displayed, so the structure was generated and then discarded. Asking a small free-tier model for four headings is also a lot of output to fund before it says anything useful (#476).
Investigating a task asks what you want to know first. The button opens a dialog with a free-text question and four starters — "How do I complete this task?", "What should I do first?", "Break this into smaller steps.", "What might go wrong here?" — that fill the field rather than firing straight away, so the question stays editable and you can see what is about to be asked. Leaving it blank still runs the general analysis it always did (#476).
An investigation shows the question you asked instead of the whole prompt that was sent. The
promptcolumn holds the entire assembled instruction — persona, quest, task, output format — which is what the model needs and several paragraphs nobody wanted to scroll past. The question is stored alongside it and is the only part now displayed (#476).The task section no longer narrates itself. "Task added.", "Task updated.", "Investigation queued." and the rest were banners announcing changes visible in the list directly below them. Errors still appear inline, because nothing else reports those (#476).
An action that happens to unlock an achievement no longer reports it twice, and no longer swallows the confirmation of what was actually done. The unlock used to be written into the inline notice as well as the toast, and callers treated that as having said enough — so queueing an investigation that earned a badge said "Achievement unlocked: AI Scout." and never said "Investigation queued." The toast is the notification; the inline notice reports the action (#476).
The location consent prompt sits above the map again. Leaflet stacks its own furniture high — map panes at z-index 400, zoom controls at 1000 — and with no stacking context of its own that competed at the page root, where it painted over the
z-50dialog that the map itself had raised. The map container is now isolated, which fixes it for every dialog rather than for one (#476).A task's location is set by dropping a pin on a map. It previously required typing a latitude and a longitude — the user had to know where "the gym" is to six decimal places before they could attach it to anything. Click the map or drag the pin to move it, "Use my current location" places it from the browser's own fix, and the check-in radius is drawn as a circle that resizes with the slider, so 100 m and 5 km are things you can see rather than infer (#476).
The coordinate fields are still there, collapsed behind "Enter coordinates" and bound to the marker in both directions. A map cannot be used with a keyboard or a screen reader, so removing them would have removed the only accessible way to set a location; pasting an exact pair is also sometimes faster than hunting for it (#476).
Opting in to location from the picker no longer switches on the "Near me" filter as a side effect. The consent prompt now records what it was raised for, and only the filter's own request turns the filter on (#476).
The location check-in permission prompt is a modal. It used to render inline in the task section, so pressing "Near me" reserved a card's worth of height in the middle of the page and left the prompt sitting beside a large empty gap. Escape and a click outside both count as "Not now" — dismissing a permission prompt is a refusal, never a grant (#476).
The Add Task form no longer strands its button. Title and Details share a row, the location panel is full-width beneath them, and "Add" has a row of its own; it used to be the last cell of a single grid row, so it sat off at the right edge and drifted further down whenever the location panel expanded inside the Details column (#476).
The task section on a quest page stopped repeating the quest. It used to open with an eyebrow reading "QUEST TASKS", a second
<h1>reading "Task management", and the quest's own title again as a subtitle — all directly beneath the quest heading that already said both. It is now a single "Tasks" heading with the progress bar under it (#476).The Compact/Expanded toggle is gone; task details and investigations always render, which is what Expanded — the default nobody had reason to change — already did (#476).
The model a quest runs on is stated once, on the quest's meta line beside the owner, the date and the year theme, instead of as a chip over its task list. It is a property of the quest, not of the task section. The standalone
/tasksworkspace keeps its own model control: it has no quest header to carry it, and its picker is the only way to choose the model an investigation runs on while the model-personas flag is off (#476).The remaining task controls are all one size, so "Complete all" and "Near me" sit on the same baseline instead of one being a head taller than the other (#476).
Every owner action on a quest now sits in one aligned row directly beneath the quest's goal. They used to be three stacked groups at the foot of the page — the lifecycle transitions, a lone "Edit Quest" button, then a row holding Share, Archive and Delete — reachable only after scrolling past the entire task list. Each of those buttons also wrapped itself in a container that held its own error text, and in a flex row those containers stretch to the row's height while the fixed-height button inside them does not, so the buttons sat at visibly different heights the moment any one of them had something to say. Every action now renders bare and reports through one message region below the row. Archive and Delete moved behind an overflow menu: still one click away, no longer competing with the transition that matters (#476).
"Share Quest" no longer moves. It used to render in the page header for a public quest and at the foot of the page for a private one; it is now always in the same place, and telling the reader a private quest must be made public first is a note under the toolbar rather than a control that appears somewhere else (#476).
Deleting a quest confirms in a dialog instead of expanding an inline card between the buttons, and dismisses on Escape or a click outside (#476).
The quest detail page leads with the quest instead of burying it. Context and constraints are collapsible sections showing their first line when closed, rather than two full cards of reference material stacked above the tasks; the quest's images are a scrollable strip of thumbnails rather than a two-column grid of
aspect-videopanels that pushed the task list most of a screen down; and the daily/weekly challenge banner is a single line rather than a card. The goal, the tasks and the owner's controls are what remains at full weight (#476).Challenge closing times are formatted in the viewer's timezone only after the page loads, on both the dashboard and
/challenges.Intl.DateTimeFormatresolves to whatever timezone the runtime defaults to — UTC on the server, the viewer's own in the browser — and challenge periods close at midnight UTC, so anyone west of it was served a different day than their browser would have rendered, a mismatch React resolves by leaving the stale one on screen. Both pages now share oneChallengeDeadlinecomponent rather than holding a copy of the formatter each (#468).The dashboard hero greets by time of day — "Good morning", "Good afternoon", "Good evening", and "Burning the midnight oil" past 10 p.m. — each with a line of its own beneath it. The hour comes from the viewer's own clock rather than the server's: the server renders a greeting that is true at any hour and the time-aware one replaces it on hydration, so a reader four hours off UTC is never told good morning at nine at night (#468).
Removed the "New Quest" button from the dashboard hero and the desktop side bar. The app shell's top bar has carried the primary call to action at every width since #461, so a signed-in user was looking at three of the same button on one screen; the top bar's is unchanged and remains the one on phones (#468).
The dashboard opens on the numbers that answer "where am I": active quests, tasks done, the current run of consecutive days something was finished, and how far along the active quests are. The streak counts the same UTC days
compute_well_played_recapdoes, so it agrees with the Well Played recap, and it survives a day with nothing finished yet rather than resetting at midnight. The row is owner-scoped — party quests belong to someone else's history. A dashboard that cannot read your statistics says so rather than reporting an empty account above a quest list that loaded fine, and the completion-rate caption tells having no active quests apart from having active quests with nothing in them yet (#468).The dashboard's Random Challenges section is now a single card listing one row per live challenge — title, prompt, cadence, XP, closing time, and one button. The two full-height cards it replaces carried the constraints block and the numbered task list as well, which is the bulk of what pushed the quest list off the first screen. The full cards are unchanged on
/challenges(#468).Tightened the dashboard's vertical rhythm: a smaller welcome panel, the year theme and the challenge card sharing a row when both are present, and the Skill Tracks block reading as a section rather than a second page heading. The quest preview keeps the page's one large heading (#468).
Added a Quests section to the navigation.
/questsis now an index of every quest the user owns or shares as a party member, filterable by status, and the task workspace that used to answer that URL moved to/tasks— the nav item pointing at it was labelled "Tasks" while its address said quests, so there was no way to reach a quest list from the nav at all. Old/quests?questId=…links redirect to the workspace, an unrecognised?status=is normalised away instead of listing everything under an inactive filter bar, and a quest's breadcrumb now returns to the Quests index instead of the dashboard (#469).Both signed-in surfaces are gated in the middleware: anonymous visitors to
/questsor/tasksget the usual login bounce, and a user with onboarding still pending is routed to the wizard from either./quests/[id]and/quests/publickeep rendering for logged-out visitors (#469).A failed quest read now says so. The Quests index and the dashboard preview show "Unable to load your quests" when the query or its policies fail, instead of rendering an empty account under an invitation to create a first quest (#469).
The dashboard now previews the four most recently updated quests with an "All quests" link instead of carrying the full filterable list; the status filters moved to the Quests page with the list, and an old
/dashboard?status=…link forwards there.listQueststakes alimitthat is applied to the quests query itself, so the preview no longer fetches task counts, party-member counts, personas and themes for the user's entire quest history (#469).Skill Tracks are their own section. They used to be listed among Quest Packs with a badge, and a track's stage map was only reachable at
/quest-packs/[slug]/track— a multi-stage learning path presented as a variant of a one-off template./skill-tracksis now an index in the nav showing every published track with the stage you reached,/skill-tracks/[slug]is the stage map, and the old track URL redirects permanently (query string, and so?stage=, preserved). The Quest Packs browser lists templates only and points at the new section (#470).The Skill Tracks index and the dashboard's resume widget report the same three states for a track — not started, in progress at stage N, and complete — and a failed read says so instead of claiming nothing is published (#470).
Skill tracks no longer appear anywhere a Quest Pack is chosen. The New Quest pack picker, the onboarding wizard's starting points, and
GET /api/quest-packsall list templates only — a track shares thequest_packstable but keeps its tasks inskill_track_stage_tasks, so picking one previewed an empty task list and cloned nothing into the quest. Arriving at/quests/newfrom a track stage still pre-fills the stage title and offers the way back to the track map; the track is resolved against the track tables rather than through the picker (#470).The AI Coach interview now ends on a decision instead of a countdown. When the draft is ready the answer field gives way to Continue and Start over, and the summary stays on screen until one is chosen — it previously flashed up for under a second before the card replaced itself with the pre-filled form. Start over begins a fresh interview in place; Cancel still leaves the coach entirely (#471).
The AI Coach's closing turn now shows the quest it drafted — title, goal, and the numbered tasks it is about to create — in place of the JSON payload it used to print (#471).
The AI Coach interview now runs on the model persona the user picked, and the picker sits above the coach card so the choice is made before the interview rather than after it.
/api/coach/interviewre-checks the key against the account's entitled personas and only uses it to REORDER the fallback chain — a key the plan does not cover is refused with402, and a provider outage on the chosen model still degrades to the next entitled one instead of failing the turn (#471).The AI Coach's suggested tasks are now created with the quest instead of being listed for the user to copy out by hand. They are shown as "Tasks the AI Coach will create", and because a Quest Pack would clone a second unrelated task list on top of them, the pack picker is disabled and says so while a coach draft is active.
createQuestSchemarejects the pair outright, and a failed task insert rolls the quest back — the same contract the pack path already had (#471).Reworked the New Quest page around progressive disclosure. Title and Goal are the only text fields on screen when the page opens; Context & constraints, Reference images, Quest Pack, Year Theme, and Model persona each moved into a collapsible section that stays closed until it is wanted and names what it holds when it is closed. Model persona leads rather than trails, so the model choice sits with the AI Coach card instead of being made after the coach has already drafted the quest. A section opens on load when it already carries content, so an AI Coach draft, a
?pack=deep link, and every quest being edited still land on a filled-in form. Collapsed sections stay mounted, so nothing needs to be opened for its value to be saved (#471).Replaced the New Quest reference-image textarea. Image URLs are added one at a time with a thumbnail preview, a remove control, and a running
n of 8count, and an invalid, duplicate, or over-limit URL is refused with an inline message instead of failing the whole save on submit. The submitted format is unchanged (#471).Moved the New Quest status select and public-quest checkbox into a compact publish row beside the submit button, and dropped the Dashboard and Quest Packs links from the page header — both have lived in the app shell's navigation since #461. The contextual “Track map” link back into a Skill Track is kept, because it has no shell entry of its own. The same changes apply to the inline quest editor, which shares the form (#471).
Added a unified navigation shell for the signed-in app: a sticky top bar with the Questify wordmark, a New Quest call to action, and an account menu, plus a persistent side bar listing Dashboard, Tasks, Public Quests, Quest Jams, Quest Packs, and Challenges. Below
mdthe side bar collapses into a drawer opened from the top bar. The current section is marked witharia-current="page"(#461).The account menu carries the signed-in identity (avatar or initials, name, email), links to Profile, links to Admin for users who pass the
is_admin()check, and holds the Sign out control that every page used to render for itself (#461).Moved
/dashboard,/quests,/quest-jams,/quest-packs,/challenges, and/profileinto an(app)route group so the shell is attached in one layout. Every URL is unchanged. Pages in the group that are reachable while logged out — a shared public quest, the public quest index, the Quest Jam list, the Quest Pack catalogue — render without the shell and keep their Sign in / Create account entry points (#461).Removed the per-page navigation button rows from the dashboard, challenges, quest packs, profile, Quest Jams, and public quests pages. Those links are now in the navigation bars, so signed-in pages no longer each ship their own ad-hoc set (#461).
Removed
LogoutButton; the account menu now holds the app's only sign-out control. The E2Elogouthelper opens the account menu before clicking it, so it no longer silently no-ops (#461).Added a free AI tier. OpenRouter is now a supported provider in both the worker and the web app, and
OPENROUTER_API_KEYis the only provider key a working install needs — investigations, quest narratives, and the AI coach all run at no cost without an OpenAI, Anthropic, or DeepSeek account.The seeded free default is
openrouter/free, a router rather than a fixed model: OpenRouter selects an available zero-cost model per request and filters for the capabilities it needs. Individual:freeslugs are retired without notice, so both provider clients retry a 400/404 from a pinned free slug against the router before failing, and investigation details record the model that actually served the request.Paid models are now behind a subscription wall. The model persona picker locks Pro personas for free accounts, labels free personas as Free rather than by cost band, and refuses to submit a locked persona. Selecting a Pro model explicitly returns 402 with an upgrade message; a Pro persona merely inherited from an existing quest is downgraded silently so generation still succeeds.
The AI coaching interview now walks the personas the account is entitled to instead of a hard-coded Anthropic → OpenAI → DeepSeek chain, which meant a free account's coaching turn silently reached a metered provider. Provider-outage fallback is preserved, capped at three attempts to stay inside the route's time budget.
Admins can set a persona's required plan from the persona admin panel (
Required Plan), defaulting to Pro.Added the first-run onboarding wizard at
/onboarding, behindNEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD(default off). A newly signed-up user is walked through four steps — profile basics (display name, avatar, theme), an optional Quest Pack or Skill Track, their first quest, and a Year Theme — with a progress indicator and resumable state. Choosing a pack in step 2 clones its tasks into the quest created in step 3, and the quest is createdactiveso the user lands on the dashboard with work in progress (#460).Every onboarding step is skippable and the whole wizard can be dismissed with "Skip setup"; skipped steps are recorded separately from completed ones, and the step the user was on when they dismissed is preserved so drop-off is attributable (#460).
Signed-in users with unfinished onboarding are routed to
/onboardingfrom protected paths by the middleware. The check is gated on the feature flag first, scoped to protected paths only, runs as a single-column primary-key lookup, and fails open on error so a database problem cannot trap a user in a redirect loop. A user with no onboarding row at all counts as pending, so an account created out of band is routed to the wizard and its row repaired rather than silently skipping onboarding forever (#460).Signup, login, and the
/auth/callbackhandler now resolve the onboarding destination themselves instead of relying on the middleware bounce, so a new account lands on/onboardingwith the address bar to match. Next renders a middleware redirect issued during a Server Action redirect but leaves the URL on the original target, which meant a user finishing signup read the wizard at a/dashboardURL. A deliberatenextdeep link is preserved, and a password-recovery session is never diverted (#460).The first-quest step is idempotent: revisiting it via Back, or retrying after a failed write, updates the quest already created instead of leaving a trail of duplicate active quests. A step revisited with a different outcome moves between
completed_stepsandskipped_stepsrather than appearing in both, and skipping the pack step clears any previously chosen pack so the first quest does not clone its tasks anyway (#460).
Fixes
A model's reasoning is no longer passed off as its answer. Reading
message.reasoningwhencontentcame back empty was added on the theory that partial thinking beats nothing; what it produced was the model's monologue under a green Completed tick — "We need to respond with concise, actionable markdown. The persona: … So we need to answer with sections: …". A model that reasoned and never committed to an answer has not answered, so the investigation now fails and says exactly that, namingAI_MAX_RESPONSE_TOKENSas the lever (#476).A finished investigation shows its first sentence rather than a markdown heading. The one-line summary is the first line that is not a "Summary" label, but the test for that only matched a bare
### Summary— so a model that wrapped its answer in a blockquote produced the summary> ### Summary. Blockquote markers are stripped before anything is judged, and the label is now recognised as a heading, in bold or underscore emphasis, with or without a colon (#476).An investigation that came back empty is no longer recorded as a success. It used to be marked completed with "Investigation completed without a text response." where the analysis should be — a green tick over an empty result and no clue why. It now fails with the reason: the token limit was reached before the model answered (naming
AI_MAX_RESPONSE_TOKENSas the lever), the content filter refused it, or whatever the provider reported.finish_reasonis recorded on successful investigations too (#476).The worker reads the responses these APIs actually send. It only ever looked at a string
message.content, so three shapes that arrive with a perfectly good 200 all came back as no text: content returned as an array of parts, which several OpenAI-compatible backends use because the request sends its prompt that way; a reasoning model'sreasoningfield, which is the only text present when the model never committed to a final answer; and the legacychoice.textshape some OpenRouter backends still emit (#476).The investigation worker no longer treats an empty queue as a broken one.
claim_next_task_investigation()returns a composite, and PostgREST reads a composite-returning function asselect * from f()— where Postgres expands a NULL composite into one row of all-NULL columns rather than zero rows. An idle queue therefore arrived as a perfectly truthy object with every field null, so the "nothing to claim" check never fired: the worker processed a phantom investigation, failed to find a task fortask_id: null, and then tried to record that failure againstid: null, which Postgres rejects withinvalid input syntax for type uuid: "null". The claim is now judged on the row's id (#476).The AI investigation worker runs on Node 22.
@supabase/realtime-jsresolves a WebSocket constructor insidecreateClient(), and Node only exposes a globalWebSocketfrom 22 onward — so on the Node 20 base image the worker threw before it reached its poll loop, exited, and was restarted forever by Docker. Every investigation stayed queued with nothing in the app to say why. The library suggests installingwsand passing it as a transport; that is the wrong repair here, because the worker never opens a realtime channel and Node 20 is past end-of-life. It now fails with a message naming the actual requirement if it is ever started on an older runtime (#476).The AI investigation worker no longer dies on a single failed poll. Any error reaching the database — Supabase not up yet, a network blip, a permission change on
claim_next_task_investigation()— escaped the loop, set a non-zero exit code and ended the process; because the container restartsunless-stopped, a recurring failure became an invisible crash loop that logged one line per restart while investigations sat queued forever with nothing in the app to say why. The loop now catches per iteration and retries with exponential backoff capped at a minute. It also names the Supabase URL it is polling at startup and logs the first successful poll, so a worker that cannot reach the database is distinguishable from one that simply has nothing to do (#476).Every modal in the app now behaves like one.
aria-modal="true"is a claim about the page rather than a mechanism: it tells assistive technology the rest of the document is inert without making it so. Opening the location consent prompt, the delete confirmation or the investigation dialog left focus on the control behind the overlay, Tab walked straight back out into a page the user could no longer see, and closing left focus wherever it had wandered. A shareduseModalFocushook supplies the three behaviours the attribute promises — move focus in, contain Tab, hand focus back — for all three at once. The delete dialog needed one extra turn: its trigger lives inside the overflow menu, which unmounts as the dialog opens, so the toolbar hands back the overflow button instead of a detached node (#476).The map no longer claims a keyboard interface it hasn't got.
role="application"tells a screen reader to stop intercepting keys and pass them to the widget; nothing on this surface answers a key, because the pin is placed by clicking and moved by dragging. It is a labelled region now, and the label points at the coordinate fields that are the actual keyboard path (#476).The quest's image thumbnails are told apart. Every link took the quest's title as its accessible name, so three of them announced the same thing three times. Nothing here knows what any individual image depicts, so the name carries its position in the set — the one distinction that is both true and useful (#476).
The overflow menu describes itself as the disclosure it is. It announced
aria-haspopup="menu"over arole="menu"panel, which promises the ARIA menu keyboard model — arrow keys moving a roving tabindex betweenmenuitems. The children are ordinary buttons and none of that existed, so a reader was told to press keys that do nothing. It is nowaria-expandedandaria-controlsover a labelled group (#476).Refusing the location prompt now cancels the request that raised it. The picker remembered that it was owed a pin as soon as consent landed, and nothing cleared that when the prompt was dismissed with "Not now" — so opting in later from somewhere else entirely, such as the "Near me" filter, dropped a pin on the task the user had already declined to locate (#476).
A failed investigation says why, where you can read it. The dialog deliberately stays open on failure so the typed question survives, but the error rendered in the task section underneath the
fixed z-50overlay — an explanation hidden behind the thing it was explaining (#476).OpenAI and Anthropic finish reasons are read, not ignored.
finish_reasonis a chat-completions field, and only two of the four providers speak it: OpenAI's Responses API reports an unfinished reply asstatus: "incomplete"with the cause inincomplete_details.reason, and Anthropic usesstop_reason. Both are normalized at the edge now, so a reply truncated by the token limit names the limit instead of arriving as a generic empty response, and a successful run records the stop reason it advertises (#476).An empty reasoning-only response no longer blames a budget that was never spent. "The model reasoned and did not answer" describes what came back, not why — reported as token exhaustion it sent the reader off to raise a limit the model had not reached. An explicit finish reason now decides the message, and reasoning-only only changes the wording when one confirms it (#476).
The repository runs on Node 22, not just the worker's Docker image.
.nvmrcstill pinned 20 whileapps/workerdeclaredengines.node >= 22, and every CI job reads that pin — so the workspace that needs a globalWebSocketwas being tested on the runtime that has not got one, and the unit-test workflow failed (#476).The AI Coach no longer answers an interview question with an article. Asked to help climb Mount Everest it would reply with facts about Everest and no follow-up question, leaving the interview stuck. The system prompt now states the per-turn rules as hard constraints — at most two short sentences then exactly one question, no teaching, no lists, no answering on the user's behalf — and the route verifies rather than trusts: a reply carrying no question, running past 700 characters, or shaped like a document is regenerated once against a tight 200-token ceiling, falling back to the original rather than failing the turn. The closing turn is exempt, since it carries the draft (#471).
The AI Coach now focuses the answer field and scrolls it into view after each reply, so a long answer no longer leaves the user hunting for where to type. It uses
block: 'nearest', which moves the page the minimum required and not at all when the field is already visible, and is skipped on the closing turn where the card is about to be replaced by the filled-in form (#471).Fixed the AI Coach printing its raw JSON payload into the chat and then leaving the quest form empty. The parser only understood a correctly fenced, correctly closed ```json block, and the chat's display filter had the same blind spot — so any reply it could not read was also one it could not hide, and the two failed together. A single
splitCoachReplynow separates prose from payload for both, reading a bare unfenced object, an unclosed fence, and trailing commentary after the block, with a brace-balanced scan so braces and escaped quotes inside string values no longer break it. A payload that still cannot be parsed is withheld from the chat and answered with a retry prompt rather than shown (#471).Raised the coach reply token cap from 800 to 1200. The last turn carries the JSON draft as well as the closing sentence, and a longer draft could be truncated mid-object — which parsed as nothing, so the interview stalled (#471).
Fixed the AI Coach interview scrolling the page instead of its own message thread. It called
scrollIntoView()on a sentinel at the end of the thread, which walks every scrollable ancestor and, with the defaultblock: 'start', parked that sentinel at the top of the viewport — pushing the reply the user was waiting to read off the top of the screen. The thread now scrolls itself, and is capped against the viewport height so the header, the newest reply, and the input stay visible together (#471).Fixed the quest form treating any pre-filled create as an edit. The mode was inferred from
defaultValuesbeing set, so once the AI Coach drafted a quest the form hid the Quest Pack picker, offered Completed / Failed / Archived in the status select for a quest that did not exist yet, and reported persona changes to analytics asquest_edit. Editing is now an explicitisEditingprop (#471).Fixed the quest edit form silently discarding a grandfathered Pro persona. For a downgraded owner the picker preselected a free substitute and submitted it through its hidden
model_persona_keyinput, so saving an unrelated edit rewrote the stored reference and re-subscribing could no longer restore the original model. The picker now starts with no selection when the stored persona is not selectable — locked behind Pro, or filtered out of the list entirely, which is what happens in the default configuration — and submits nothing until the user actively chooses a replacement.POST /api/billing/checkoutrefuses an account that already holds a Pro-granting subscription, with a 409 pointing at the Billing Portal. Hiding the buy buttons is not enforcement: a double click, a retry, or a direct call would open a second Checkout session, and Stripe will happily run two independent subscriptions for one customer (#486).Stripe customer creation carries an idempotency key derived from the user id, so two concurrent first checkouts converge on one customer. Without it both saw no mapping, both created a customer, and the losing upsert left one holding a live Checkout session and its invoices that neither the mapping nor the Billing Portal could find again (#486).
Locked personas in the model picker render as plain cards rather than disabled buttons. The "Upgrade to Pro" link was interactive content nested inside a disabled
<button>— invalid HTML, and unreachable by pointer and assistive technology alike, so the one control that could unlock the card was the one control that could not be used (#486).The dev TUI re-checks for a live-mode context after an account switch. The guard ran before the switch was offered, so choosing a live account from the picker walked straight past it into fetching a live webhook signing secret into
.envand forwarding production events at localhost (#486).selectChallengeForPeriod()breaks slug ties by byte order rather thanlocaleCompare(). The database sorts the same candidates withcollate "C", and the two have to agree or they choose different challenges; slugs are constrained to^[a-z0-9-]+$, where the two orderings are identical. It also drops a locale dependency that made the pick depend on where the code happened to run (#477).The completion prompt counts the tasks that are actually outstanding. The owner toolbar is server-rendered once while the task section below it mutates tasks in place, so a page loaded at 3 of 10 still offered to finish "7 tasks" after two had been ticked off. The counts are re-read before the question is asked — in the database, not by measuring an array, since PostgREST caps an unpaginated read at
max_rows— and a failed read falls back to the rendered numbers rather than blocking the transition (#477).Quest status now changes what a quest does, not just its badge. Archived quests can no longer be edited or have their tasks changed, completed and failed quests have their task lists frozen until they are set back to active, and a draft quest's tasks can be written and reordered but not started or completed. The quest detail page and the task manager state the current restriction and disable the controls it blocks, and the task API answers a blocked write with
409and the same message (#462).Added lifecycle buttons to the Quest Actions section — Start Quest, Mark Complete, Mark Failed, Reopen Quest and Unarchive Quest, offered according to the quest's current status. Moving a draft quest to active previously required finding the status select inside the edit form, and an archived quest had no way back at all because its only status control was in an editor the archive should not have allowed (#462).
Security
Stripe configuration is checked per capability rather than as one predicate over every value. The webhook and the portal use no price at all, so requiring both price ids meant a retired price would 503 lifecycle events for subscriptions that are still being charged — Stripe retries those for days and then disables the endpoint — while locking those same subscribers out of the only page where they can cancel or fix a card (#486).
start_quest_challenge()refuses a challenge that is not the one on offer. Exactly one active template per cadence is the challenge for a given day or ISO week, chosen deterministically from the period key — but the function checked onlyis_active, and the uniqueness constraint is per template, so an authenticated caller could POST the RPC once per template and run the entire catalogue in a single period. The rule is an authorization rule and now lives at the database boundary:selected_challenge_template()recomputes the choice in SQL, andchallenge_period_index()is the same FNV-1a the app computes, verified identical across 70 key/count pairs and 453 consecutive periods against the seeded catalogue. With six active daily templates, walking the catalogue now yields one run and five refusals (#477).sync_quest_completion()is no longer executable byPUBLIC.CREATE FUNCTIONgrants EXECUTE toPUBLICby default and PostgREST turns every executable public-schema function into an RPC, so aSECURITY DEFINERhelper that takes any quest id and bypasses RLS was reachable by any authenticated caller — enough to re-close a quest its owner had deliberately reopened.sync_party_quest_completion(), which it replaces, carried the same hole since #263; it was narrower only because it returned early below two party members. The four internal task-guard helpers were locked down with it (#477).Closed a plan-tier bypass found in review:
task_investigations_guard_model_typeskipped validation whenmodel_typewas unchanged, so a downgraded owner could PATCH an old paid investigation's status back topendingthrough PostgREST and have the worker claim and bill the paid model again. Every user-originated write to that table is now revalidated. The quest guard keeps its unchanged-persona exemption — editing a quest's title is not billable, whereas an investigation is billed every time it is claimed.Onboarding Server Actions no longer render raw Supabase/Postgres error text. Failures the user cannot act on are logged server-side and surface as stable copy, so table, policy, and constraint details stay out of the UI (#460).
Technical changes· 83 entries for database, tooling, CI, tests and docs
Database
apply_subscription_state()now records the Stripe event id itself, in the same transaction as the write it guards, and returns false when that id was already recorded. Recording it from a separate statement — as the webhook did — left a window in which a process killed between the record and the write leaves a record with no work behind it, and every Stripe retry then reads as a duplicate: a subscription that is paid for, never granted, and reports no error anywhere. A crash now rolls back both (#486).apply_subscription_state()takes a row lock on the owner's profile before it writes. Two events for different subscriptions of the same account each derived the tier from a snapshot that could not see the other's uncommitted rows, so whichever committed last won — a newactivesubscription racing an old one's cancellation could leave the account on Free while Stripe charged for Pro (#486).Added
profiles.plan_tier_source, recording whether the current tier was granted administratively or by billing, and taught reconciliation to revoke only what billing granted. The billing panel already surfaced accounts "granted Pro directly rather than through a subscription", and without an owner recorded on the tier the next lifecycle event for such an account — an abandoned checkout goingincomplete, a retry of an old cancellation — computedfreeand silently revoked the comp.set_user_plan_tier()marks the tier administratively owned, and the existing self-service guard now covers the new column, so an account holder cannot re-label a billing grant as permanent (#486).Auto-completion and task writes now serialise on the parent quest row. "Is anything still outstanding?" is a predicate read followed by a write, and without a lock a concurrent INSERT could slip a fresh todo task between the two: both transactions passed their own guard, both committed, and the quest ended up completed with unfinished work.
assert_quest_accepts_task_writes()— which every task write already funnels through — takesFOR UPDATE, andsync_quest_completion()takes the same lock before counting.FOR UPDATErather thanFOR SHARE, because the completion trigger wants the exclusive lock moments later in the same transaction and two concurrent task writes would otherwise deadlock upgrading it. A task move locks its two quests in ascending id order so two moves in opposite directions cannot deadlock either (#477).start_quest_challenge()is idempotent under concurrent starts. The existing-attempt check could be missed by two requests at once; both created a quest and cloned its checklist, and the loser hit the(user_id, challenge_template_id, period_key)unique constraint and returned a generic error, having left behind exactly the orphan quest this function was written to prevent. The inserts now sit in a subtransaction that catchesunique_violation, rolls the half-built quest back and returns the winner's quest — which is what starting a challenge twice in one period should give you (#477).A quest now closes itself when its tasks are done.
sync_party_quest_completion()had done this for Party Quests since #263 but returned early for anything with fewer than two members, so the rule applied to exactly the quests most people do not have. It is generalised intosync_quest_completion()rather than duplicated: a solo quest is the party rule with one member. Only an ACTIVE quest closes — a draft has no progress to finish and a completed, failed or archived one must not be revived by a stray task write — and a quest with no tasks never completes itself. A party of two or more set toall_assigned_tasksstill counts only its assigned tasks.tasks_sync_party_quest_completionis renamedtasks_sync_quest_completion(#477).Added
public.complete_quest(uuid, quest_status, boolean). Marking a quest complete can now finish its outstanding tasks with it, but only when the caller asks: doing it unconditionally would overwrite the record of what was actually done, and reopening the quest would not bring the distinction back. It marks the tasks first, because a completed quest refuses task writes (#462), and its compare-and-set acceptscompletedalongside the caller's expected status so the auto-completion its own task writes trigger is not mistaken for a lost race (#477).Challenge tasks are now fixed in the database.
tasks.originrecords whether a task's wording belongs to its owner (user) or to whatever created the quest (template),tasks.origin_refrecords the template row it was cloned from, andquests.allows_extra_tasksrecords whether the list is closed at all.tasks_guard_template_originlets a locked task be worked — status, assignee,sort_order, location andextra_content, named intask_owner_editable_columns()— and refuses its title, details, a move to another quest, and deletion;quests_guard_task_policykeepsallows_extra_tasksout of a caller's hands entirely. Exemptions matchassert_quest_accepts_task_writes()(service role, trigger-originated writes) plus a transaction-scoped GUC aSECURITY DEFINERclone sets and clears around its own inserts. Quest Packs are deliberately not locked: a pack is a starting point meant to be adapted (#477).Added
public.start_quest_challenge(uuid, text), which creates the quest, clones the challenge's checklist as locked tasks and records the attempt in one transaction. It replaces three separate client writes with hand-rolled compensating deletes — best-effort ones, so a failure between them left an orphan quest carrying the challenge's tasks and no attempt row, which the dashboard then offered to start again. It also decides whether the named period is open from the server clock instead of trusting the value posted in the form, and returns the existing quest when the challenge has already been started this period (#477).get_my_active_quests()returnsallows_extra_tasks, so the/tasksworkspace can close its add-a-task form rather than failing on submit (#477).Dropped
quest_challenge_templates.reward_xp_bonusandquest_challenge_attempts.xp_bonus_awarded. They were the only mention of XP in the product — there is no ledger, no total on a profile and no level, and nothing read either column. Removing the reward also simplifies both challenge trigger functions:set_quest_challenge_attempt_derived_fields()no longer re-reads the template on every update to recover a value it merged withgreatest()(#477).Removed the Skill Track schema:
skill_track_stages,skill_track_stage_tasks,user_skill_track_progress, theadvance_skill_track_stage()function, and thequest_packs.typediscriminator with its partial index. Track packs are deleted rather than converted, since their tasks lived in a table that is going away and a converted pack would sit in the browser with an empty task list.quests.quest_pack_idanduser_onboarding.selected_quest_pack_idare bothon delete set null, so quests started from a track keep their content (#477).claim_next_task_investigation()now also reclaims investigations left inrunningby a worker that died mid-flight. It only ever looked atpending, so a row claimed by a process that then exited was orphaned permanently — no worker would look at it again, and the task showed it as in progress forever. Arunningrow untouched for ten minutes is reclaimable; generation takes tens of seconds against every provider in the catalog, so that cannot overlap a worker that is merely slow, andfor update skip lockedalready prevents two live workers claiming the same row (#476).Added
public.get_dashboard_stats(), which aggregates the dashboard's four headline numbers in one round trip: active quests, tasks done, task progress across active quests, and the current run of consecutive UTC days with a completed task. It replaces a client-side fold that read every quest id and then every task row — PostgREST caps an unpaginated read atmax_rows(1000), so past a thousand tasks the numbers were silently understated, and thequest_id in (…)filter would have outgrown the request URL well before an account reached a thousand quests.SECURITY INVOKERreadingauth.uid(), so RLS still applies and no caller can ask for another account's statistics. Backed bytasks (quest_id, status)andtasks (updated_at) where status = 'done'(#468).Quest status is now enforced in the database.
tasks_guard_quest_statusrefuses task inserts, updates and deletes that the parent quest's status does not allow — a draft quest accepts task edits but not task progress, and a completed, failed or archived quest accepts neither — whilequests_guard_archived_updatesfreezes an archived quest itself so unarchiving (or soft deletion) is the only write it takes. Both exempt service-role callers and trigger-initiated writes such as party-member assignment clearing, and both raise42501with a message the app can show verbatim. Addedpublic.quest_status_accepts_task_writes()andpublic.quest_status_accepts_task_progress()as the shared predicates (#462).Added a two-tier AI entitlement model:
profiles.plan_tier(free|pro, defaults tofree) andmodel_personas.required_tier(free|pro, defaults toproso a newly catalogued model is never accidentally free). A persona is usable whenplan_tier_rank(required_tier) <= plan_tier_rank(current_plan_tier()), exposed aspublic.user_can_use_model_persona(uuid)andpublic.user_can_use_model_type(text).Enforced plan tiers with three triggers rather than route-handler checks.
profiles_guard_plan_tierblocks a user changing their ownplan_tier;quests_guard_model_personarejects a quest pointed at a persona above the owner's tier;task_investigations_guard_model_typerejects an investigation naming a model above the caller's tier or outside the persona catalog. The last one is load-bearing —task_investigationsis user-insertable through PostgREST, so anyone could have queued a paid model directly and let the worker bill it. Service-role callers have noauth.uid()and pass through all three.Quests keep working after a downgrade. The quest trigger only fires when
model_persona_idactually changes, so a quest created while subscribed stays editable on a free plan and keeps its stored persona; generation substitutes an entitled model at run time instead of failing.Added
public.set_user_plan_tier(uuid, text), the only supported way to move an account between tiers. It requires the newai_plans.writeprivilege (held by SuperAdmin and Admin); a payment-provider webhook will call the same function with the service role when billing lands.Seeded two zero-cost OpenRouter personas —
free-router(openrouter/free) andfree-gpt-oss-20b— and left the six existing OpenAI/Anthropic/DeepSeek personas on theprodefault. Added 17 pgTAP assertions covering tier defaults, self-promotion, quest and investigation writes at both tiers, grandfathering, and the admin grant path.Added
public.user_onboarding(one row per user) and thepublic.onboarding_stepenum to track first-run onboarding: the step the wizard resumes on, which steps were completed, which were explicitly skipped, the chosen Quest Pack, and the quest created during the flow. RLS is owner-only for select/insert/update with no delete grant, mirroringpublic.profiles.handle_new_user()now creates the onboarding row alongside the profile row on signup, and existing users are backfilled as already-onboarded so the wizard never appears for them (#460).
Tooling & Configuration
Ctrl+D at a dev-TUI prompt is now treated as a cancel instead of surfacing readline's
AbortError, and a prompt opened on an already-closed input stream returns immediately rather than hanging the TUI with no way back.Added an API keys section to
pnpm dev:tui(alsopnpm dev:tui -- keys). It lists the provider credentials with their current values masked, reads new ones without echoing them to the terminal, validates before saving, and writes to the gitignored root.envatomically at mode 0600. A single-clears a key; a blank answer leaves it unchanged. Getting a fresh checkout running AI is now "paste the OpenRouter key" rather than working out which file to hand-write.The keys action refuses
SUPABASE_SERVICE_ROLE_KEYandSUPABASE_DB_URLby name, explaining that the start and seed workflows inject them fromsupabase status— a hand-written value there is overwritten on the next run and wrong until then.Values are validated at the prompt rather than at boot.
getServerEnv()parses these with Zod, so a malformedOPENROUTER_SITE_URLdoes not degrade one feature, it throws on every server route; the TUI rejects it before it reaches.env.Added
OPENROUTER_API_KEY, plus the optionalOPENROUTER_SITE_URL/OPENROUTER_APP_NAMEattribution headers, to.env.example,apps/web/src/lib/env.ts,apps/worker/src/env.ts, andturbo.json.Added
NEXT_PUBLIC_FEATURE_PAID_MODEL_UPSELL(default off) acrossenv.ts,.env.example,apps/web/Dockerfile,docker-compose.yml, andturbo.json. It controls the upsell surface only: off, the picker lists just what the account can run; on, Pro personas render as locked upgrade prompts. Entitlement is enforced in Postgres either way and cannot be flagged off.Added a Feature flags section to
pnpm dev:tui(alsopnpm dev:tui -- flags). It lists everyNEXT_PUBLIC_*toggle documented in.env.examplewith the value the app will actually see, distinguishes "explicitly off" from "never set", writes selections to the gitignored root.envatomically at mode 0600, and offers the rebuild those build-time values require. The catalog is derived from.env.examplerather than hard-coded, and apnpm test:dev-tuicase fails if a flag reaches the Docker build without being documented there (#460).Documented
NEXT_PUBLIC_FEATURE_STALL_DETECTIONin.env.example. It was validated inenv.tsand passed as a Docker build arg but never documented, so there was no way to discover or enable it (#460).Added the
NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARDfeature flag toapps/web/src/lib/env.tsand.env.example, defaulting to off (#460).Plumbed
NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARDthroughapps/web/Dockerfile(build arg + env) anddocker-compose.yml. It was declared inenv.tsand.env.examplebut not in the container build, so the wizard could not be switched on in the Docker/dev-TUI stack at all (#460).Dockerized local development:
apps/web/Dockerfilebuilds a multi-stage Next.js standalone image,apps/worker/Dockerfilebuilds the AI investigation worker, and a rootdocker-compose.ymlruns both against the Supabase CLI local stack. A clean checkout works with no.env— Supabase falls back to the CLI's well-known local demo keys (#459).Added
pnpm dev:tui, an interactive local-stack control plane with live Docker/Supabase/web/worker/app status badges andnuke(nuke-and-pave),start,seed,reset-db,stop,status, andlogsactions. Every action also runs non-interactively viapnpm dev:tui -- <action> [--dry-run], with--yesavailable on the destructivenukeandreset-dbactions to skip the typed confirmation (#459).Dev TUI safety rails: destructive actions require a typed project-name confirmation, are scoped to this checkout's Compose project and the local Supabase CLI project, refuse non-local Docker daemons, and never run machine-wide Docker prunes or
--linked/--db-urlSupabase commands. Stack actions read the active local credentials fromsupabase statusand pass them explicitly, so a.envpointing at a hosted project is never used for local builds (#459).pnpm dev:tui -- seedprovisions deterministic local test users —superadmin@questify.test(SuperAdmin),admin@questify.test(Admin), andmember@questify.test(no admin roles), passwordQuestifyDevTest123!. The seeder only talks to loopback Supabase URLs and only replaces users it created, identified by anapp_metadata.dev_fixturemarker (#459).Added
GET /api/health, a dependency-free liveness probe returning{ "status": "ok" }, used by the DockerHEALTHCHECKand the dev TUI's post-start verification (#459).Server-side Supabase clients (
server.ts,service-role.ts,middleware.ts) now resolve their transport URL throughsupabaseServerUrl(), which prefers the runtime-onlySUPABASE_INTERNAL_URLso SSR and the worker can reach the host-published Supabase gateway from inside a container. The auth session cookie name is derived from the public URL on both browser and server viasupabaseAuthCookieName(), keeping one session across the split URLs. Both are no-ops outside Docker, where the variable is unset (#459).apps/web/next.config.mjsemitsoutput: 'standalone'with a workspace-rootoutputFileTracingRootonly whenDOCKER_BUILD=1, leaving Vercel andnext devbuilds unchanged;DOCKER_BUILDandSUPABASE_INTERNAL_URLare declared inturbo.jsonglobalEnv(#459).Added root scripts
docker:up,docker:down,docker:build,docker:build:worker,docker:config, andtest:dev-tui;pnpm testnow runs the dev TUI suite beforeturbo run test(#459).
CI/CD
The Playwright job now runs as a matrix over
NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARD, so the onboarding wizard is exercised with the flag both off and on instead of only in its default state.NEXT_PUBLIC_FEATURE_ONBOARDING_WIZARDwas added to thebuild,dev, andtest:e2eenv lists inturbo.jsonand to the PlaywrightwebServerenv allow-list so the flag actually reaches the app under test (#460).Pinned the Supabase CLI in the pgTAP job to 2.105.0, matching the db-push job and the lockfile. The job installed
latest, so a newer local Postgres image drifted out from under the repo and failed every run since June withpermission denied for table profiles— on PRs that changed no migrations (#460).
Tests
Added 4 pgTAP assertions for challenge selection: the chooser follows the period index rather than activity alone, an active-but-not-selected template is refused, the selected one starts, and walking the catalogue yields one attempt. Fixtures now take the day by deactivating the other templates of their cadence, since being active no longer implies being startable. Total 138 (#477).
Added 8 pgTAP assertions for the PR #477 review fixes: the definer helpers are not
PUBLIC-executable, both sides of the completion race request the quest's row lock, and starting a challenge twice returns one quest with no orphan left behind. Total 134. The two races themselves were reproduced and re-tested with real concurrent sessions — without the lock, a completed quest with one outstanding task; with it, the racing insert is refused (#477).Added Vitest coverage for the completion prompt's fresh count read: it prefers the server's answer over the rendered props, skips the question entirely when the fresh counts say everything is done, and falls back to the props when the read fails (#477).
Added 15 pgTAP assertions for quest completion: a quest with no tasks does not complete itself, finishing some tasks does not close it, finishing the last one does and stamps
completed_at, reopening is not immediately undone, andcomplete_quest()honours_complete_tasksin both directions while still refusing a stale expected status. Total 126 (#477).Added Vitest coverage for the completion dialog (straight through when nothing is outstanding or there are no tasks, both answers, cancel writing nothing, and other transitions never asking), for
setQuestStatusActionrouting completion throughcomplete_questwhile leaving other transitions on the plain update, and for the task section's single re-read when the last task closes the quest (#477).Added 17 pgTAP assertions for template task locking: starting a challenge clones a locked checklist onto a closed quest, a second start in the same period returns the first quest, a stale period key is refused, a locked task can be completed, reordered and pinned to a place but not renamed, rewritten, moved to another quest or deleted, nothing can be added to a closed list, the list cannot be unlocked, and neither the lock nor the closed list can be minted by a caller. Total 111 (#477).
Added Vitest coverage for the new task-provenance helpers, the
start_quest_challengeaction (one round trip, expected vs unexpected database errors, anonymous redirect), the two new route guards on/api/tasksand/api/tasks/[id], and the TaskManager's locked-task rendering — including that a locked task keeps its progress and reorder controls, since a UI stricter than the trigger would make a challenge impossible to finish (#477).Added Vitest coverage for the Quests section: the shared quest list (cards linked to their detail page, the caller's empty message with the create CTA, the error state taking precedence over both, the model-persona flag),
listQuests' new query limit and its error propagation, the nav model's separation of Quests from Tasks, and the middleware protection on/questsand/tasksincluding the onboarding check. Extended the navigation Playwright spec to reach the Quests section from the side bar. Coverage ratchet raised to 37.3 / 76.8 / 59.2 / 37.3 (#469).Added Vitest coverage for the coach review step: nothing hands over without Continue, the answer field is replaced by the two choices, Continue is focused and scrolled to, Start over clears the draft and restores a blank interview without cancelling, and Cancel with a draft waiting abandons it (#471).
Added Vitest coverage for the runaway-reply guard (no question, a question buried after an essay, heading- and list-shaped replies, and the draft-carrying closing turn exempted) and for the route's corrective regeneration, including the fall-back when the second attempt is no better (#471).
Added Vitest coverage for the coach reply parser (fenced, bare, unclosed-fence, trailing commentary, braces and escaped quotes inside strings, truncated payloads, and prose that merely contains a brace) and for the interview component (payload never rendered in any shape, draft summary and task preview shown, retry prompt on a truncated payload, persona sent each turn, cancel abandoning a pending draft, and the thread scrolling rather than the page) (#471).
Added Vitest coverage for the coach wiring: the interview route (chosen persona used, reorder-not-replace fallback,
402for an unentitled key, malformed key ignored),defaultPersonaFor/personasForTier,createQuestSchema's coach-task rules including the pack-plus-coach rejection, andCoachingQuestCreator's ordering, persona hand-off, and survival of the draft-triggered form remount (#471).Added Vitest coverage for the New Quest rework:
FormSection(default-closed, opened when seeded, children mounted while collapsed, summary in place of description),ImageUrlList(add, remove, Enter-to-add without submitting the form, and rejection of non-URLs, non-http protocols, commas thatparseImageUrlswould split, duplicates, and the ninth image), andQuestForm's disclosure behaviour, collapsed-field submission, persona-name summary, and create-vs-edit status options (#471).Raised the
apps/webcoverage ratchet to 37% statements / 76.8% branches / 59.1% functions / 37% lines, the new floor after the New Quest rework (measured 37.31 / 77.12 / 59.4 / 37.31 at 666 tests), keeping the usual margin for Node-version drift (#471).Added Vitest coverage for the quest lifecycle rules: the shared state helpers,
setQuestStatusAction(valid transitions, rejected skips, unknown statuses, unowned quests), the task API's409responses for draft, completed and archived quests, the lifecycle buttons, and the task manager's disabled controls on draft and archived quests (#462).Added a Playwright case covering the draft-quest hold: the lock message on the quest page, task creation still allowed, the status control disabled, and both released by Start Quest (#462).
Added pgTAP coverage for the quest-state triggers — task writes allowed and refused across draft, active, completed and archived, plus the archived quest's own read-only behaviour, a task move out of a frozen quest, and an unarchive that tries to carry a content edit — bringing the plan to 86 (#462).
Added Vitest coverage for the navigation model (active-section matching, including the
/questsvs/quests/publicoverlap, and admin-only visibility), the side bar, and the top bar's drawer, account menu, Escape handling, and sign-out form. Added a Playwright spec covering section-to-section navigation, the active marker, sign-out, and the absence of the shell for signed-out visitors. A source-level guard asserts that every module importing a nav item list declares'use client', since the lists carry icon component references that cannot cross the RSC boundary — a failure Vitest cannot otherwise see, because it renders everything as plain React (#461).Raised the
apps/webcoverage ratchet to 27.2% statements / 71.2% branches / 50% functions / 27.2% lines, the new floor after the navigation work (measured 27.76 / 71.91 / 50.62 / 27.76), keeping the usual margin for Node-version drift (#461).Added regression coverage for both Codex review findings: a pgTAP case asserting a downgraded owner cannot re-queue a Pro investigation by resetting its status (verified to fail against the pre-fix trigger), and
quest-form.test.tsxasserting the edit form submits nomodel_persona_keywhen the stored persona is locked or filtered out, while ordinary and Pro-tier edits still round-trip.Added
scripts/dev-tui/env-keys.test.mjs(28 cases): masking never reveals the middle of a value and is fixed width so it cannot leak the length, a pasted line break is rejected, per-entry URL and email rules hold,KEY=""reads as unset, rendering preserves comments and collapses duplicates, and the writer validates everything before touching the filesystem so a bad key in a batch cannot leave a half-saved.env. Two drift guards assert every key the TUI offers is documented in.env.exampleand actually read by one of the env schemas.Added dev-TUI cases asserting the keys action never prints a configured secret, lists every managed credential, and leaves
.envuntouched on--dry-run.Ratcheted the web coverage thresholds to 30.4% statements/lines, 74.3% branches, and 52.6% functions — the floor for the combined suite once this branch and the navigation work are merged (measured 30.78 / 74.69 / 52.99 / 30.78 across 67 files and 499 tests), keeping the usual margin for Node-version drift.
src/lib/aisits at ~92% statements andcomponents/layoutat ~93%.Added Vitest coverage for the entitlement layer: tier comparison against the SQL it mirrors, persona resolution (explicit request vs. inherited downgrade vs. missing persona), read failures resolving to
free, the shared provider client's model-type parsing, system-message handling per provider, and the OpenRouter free-slug retry — including that a 401/429/500 is not retried, since a different model would fail identically and burn a second request.Added persona-picker cases for locked cards: a locked persona cannot be selected or submitted,
recommendeddoes not override entitlement, a free persona is labelled Free rather than by cost band, and the picker renders nothing when the tier covers no persona at all.Added Vitest coverage for the auth flows' post-login destination — signup, login, and the
/auth/callbackhandler across both flag states, deep links, and the password-recovery guard (#460).Added Vitest coverage for the onboarding wizard: step-sequence helpers, Zod validators for all four steps, onboarding queries (including the middleware probe's missing-row and fail-open behaviour), every Server Action (advance, skip, back, dismiss, first-quest idempotency, outcome-array corrections, validation and write-failure paths), and component tests for the wizard shell, progress indicator, step footer, and pack picker (#460).
Added pgTAP RLS coverage for
public.user_onboarding— owner read/update, cross-user read and update denial, insert-on-behalf-of denial, and anonymous denial — bringing the plan to 50 (#460).Added
apps/web/e2e/onboarding.spec.tscovering the anonymous login bounce, protected-path redirect ordering, flag-off inertness, the full four-step flow, single-step skip, whole-wizard dismissal, back navigation, and that revisiting the first-quest step renames the quest instead of creating a second one (#460).Added
src/test/react-dom-form-hooks.ts, a shim supplyinguseFormState/useFormStatusunder Vitest so components built on Server Action forms can be tested at all — the standalonereact-dom@18package does not ship the hooks Next.js provides via its vendored React (#460).Raised the web coverage ratchet to 33.3% statements/lines, 74.3% branches, and 56% functions — the floor for the combined suite once the plan-wall work and this branch are merged (measured 33.73 / 74.61 / 56.56 across 73 files and 571 tests). The branch gate stays at its previous 74.3% rather than dropping to the newly measured floor (#462).
Raised the web coverage ratchet to 26.5% statements/lines, 70% branches, and 49% functions (measured 26.9 / 70.58 / 49.34) (#460).
Added
node:testcoverage for the dev TUI — workflow-plan step ordering and safety invariants, the process runner, and the seeder's loopback/fixture-marker guards — run viapnpm test:dev-tui(#459).Added Vitest coverage for
/api/health,supabaseServerUrl()/supabaseAuthCookieName(), andcreateServiceRoleClient(); the web coverage ratchet moves to 22.2% statements/lines, 67.25% branches, and 43.5% functions (#459).
Refactors
Removed Skill Tracks. The feature never recorded a row of user progress: nothing called
startSkillTrack()or theadvance_skill_track_stage()RPC, souser_skill_track_progresswas permanently empty, every visitor saw stage 1 as "current" with stages 2..N padlocked forever, the dashboard resume widget could never render, and "track complete" was unreachable. Starting a stage cloned nothing either — it prefilled a quest title and dropped the stage's tasks — so a track constrained its tasks less than a Quest Pack did, not more. Gone with it:/skill-tracksand/skill-tracks/[slug], the resume widget, the stage map, both lib modules, the admin pack-type toggle, the?stage=prefill and "Track map" link on New Quest, the/quest-packs/:slug/trackredirect added in #470, and the three pack surfaces that had to filtertypeout (#477).Extracted
scripts/dev-tui/env-file.mjs. The feature-flag and API-key sections edit the same.env, and the parts worth getting right — preserving unrelated lines and comments, collapsing duplicate assignments, refusing a symlinked file, writing atomically at 0600 — are identical for both.env-flags.mjsnow delegates to it and lost ~55 lines.Extracted
apps/web/src/lib/ai/providers.ts. The narrative route and the coach route each carried their own copy of the provider fetch, error extraction, and dispatch logic — both annotated "no shared package yet" — and adding OpenRouter would have made that three copies of four providers. The two routes shed ~250 lines between them. The worker keeps its own client: it needs image parts, long timeouts, and per-investigation usage accounting.
Documentation
Added
reports/challenges-skill-tracks-review.md, the audit these changes came out of: what each feature claimed, what it actually enforced, and the four options considered before Skill Tracks was removed and Challenges was constrained in the database (#477).Documented plan tiers in
docs/ai-investigations.md(the free tier and OpenRouter's rate limits, the enforcement-trigger table, the grandfathering rule, the admin grant path, and the upsell flag) and inREADME.md. Recorded the tier rules and the shared web provider client inAGENTS.md.Added a "Turning on a feature flag locally" section to
docs/local-docker.md. A clean checkout has no.env, so everyNEXT_PUBLIC_FEATURE_*flag stays off through a nuke-and-pave until one is set — and because the value is compiled into the bundle, the image has to be rebuilt rather than just restarted (#460).Added
docs/local-docker.mdcovering the dev TUI actions, safety rails, seeded users, plain Docker commands, and the build-time vs runtime environment split, plus a "Docker Quick Start (dev TUI)" section inREADME.md(#459).Documented
SUPABASE_INTERNAL_URLin.env.example, and made updatingCHANGELOG.mdan explicit per-PR requirement inAGENTS.md(#459).