This is a submission for the Hacktoberfest Open-Source AI Challenge Week 4: Ghost in the Machine.
What I Built
Every website has visitors who leave without saying why.
Marisol tried to order on the bus. The page took over three seconds to load, came out wider than her phone, and the plus button was smaller than her thumbnail. Tomás read the web by ear. His screen reader said "textbox" with no name, and the "Place order" button never got focus, so for him it didn't exist. Abuela Rosa set the ofrenda every November. Even at 200% zoom, the light grey text was too faint to read.
None of them filed a bug report. They just closed the tab.
Ghost Visitors brings them back. Each one is a ghost: a persona driven by Gemma 4 running locally, wearing a real browser set up like their device. A ghost gets a goal ("order some bread for the Day of the Dead"), browses your site the way that person did, and leaves a haunting report in their own voice: a verdict, a tombstone epitaph, and what scared them away.
That's how I read this week's theme. Día de los Muertos isn't about fear. It's about remembering people and setting a place for them. Ghost Visitors sets a place for the visitors who quietly gave up.
One rule keeps it honest: Gemma speaks, code checks. A small model will happily invent complaints. So the browser measures things (load time, page width, tap target sizes, accessible names, font sizes, contrast), and a complaint only counts as a haunting if it cites a measurement of the same kind. Everything else becomes a whisper: still shown, but marked unproven, with the reason it was set aside.
Demo
Here is Marisol's report from the broken demo bakery. Three hauntings, each with the measurement that proves it:
And here is why the guard exists. On the fixed bakery, Gemma still had Marisol call the site "a nightmare" with "tiny buttons". Nothing the browser measured backs that, so both complaints become whispers:
The best moment was Tomás, keyboard only, on the fixed bakery. These are his real steps from the eval:
6. [/order] I need to select a bread item first.
→ pressed Tab; focus is on [3] combobox "Bread"
7. [/order] The bread selection is open, I will choose the Pan de muerto.
→ typed 'Pan de muerto ($6)' into [3] 'Bread'
...
11. [/order] The screen reader is focused on the Email field; I will input an email and then submit the order.
→ typed 'tomas@example.com' into [5] 'Email'
12. [/order] The email is filled, now I must press the place order button.
→ pressed Enter; now on /thanks
On the broken bakery, the same ghost heard textbox (no name announced) where the email field should be. Then he tabbed past the quantity buttons, and focus fell off the end of the page, because "Place order" there is a clickable div. He circled for 32 steps and wrote his own epitaph: "Here lies Tomás, who ordered bread but never found the 'Place Order' button."
Try it yourself (the model download is about 4 GB):
ollama pull gemma4:e2b
pip install -e . && playwright install chromium
ghost-visitors bakery --mode broken &
ghost-visitors haunt http://127.0.0.1:8001 --open
Or run ghost-visitors web for a small local UI where you pick a URL, the ghosts, and their goal.
Code
Ghost Visitors 👻
Persona ghosts, driven by a local open-weight model (Gemma), haunt your website and report what scared them away.
Every site has visitors who quietly leave and never say why: the commuter whose phone shows a page too wide for the screen, the blind visitor whose screen reader announces "edit text, blank", the grandmother who can't read faint grey text even with her glasses on. Ghost Visitors brings them back as ghosts. Each ghost browses your site the way that person really did, decides where to go with Gemma, and writes a haunting report in their own voice.
The rule that keeps it honest: Gemma speaks, code checks. A complaint only counts as a haunting when it cites something the browser actually measured. Anything else is shown as a whisper: kept, but marked unproven.
On the fixed bakery, Gemma still had Marisol call the site "a nightmare"…
MIT licensed. Python, Playwright, Flask, and any OpenAI-compatible endpoint (Ollama by default). The repo includes the demo bakery, 46 tests that run a real browser without a model, and every raw eval run.
How I Built It
The three ghosts
| Ghost | How they browse | What the browser measures for them |
|---|---|---|
| Marisol | 375px phone, touch, 300 ms latency, 750 kbps | Load over 3 s, page wider than the screen, tap targets under 24px |
| Tomás | Keyboard only. Perceives only what a screen reader announces | Controls with no accessible name, clickable things Tab can't reach |
| Abuela Rosa | Laptop at 200% zoom | Text under 12px, contrast below 4.5:1 |
Each ghost has a body and a lens. The body is a Playwright browser context: viewport, device scale factor, touch, and network throttling through the Chrome DevTools Protocol. The lens is the short list of things the browser measures for that person. Tomás's body is the strictest. Controls that Tab can't reach are left out of what he perceives, because for him they don't exist.
One step of a visit
-
Observe. A script scans the page and returns controls with their accessible names, the focused element, visible text, and measurements. Each new measurement gets an evidence id:
E2 horizontal_scroll on /: page is 981px wide on a 375px screen. -
Decide. Gemma reads that perception as the persona and answers with one JSON action:
click,fill,select,scroll,back, or for Tomástab,shift_tab,type,enter,space. Each answer also has a thought and a "fright" score from 0 to 10. -
Act. Playwright performs the action and tells the ghost what a person would notice:
the form was not sent, the browser points at [5]: 'Please fill out this field.', or for Tomás,focus is on [4] textbox "Your name".
When the visit ends, Gemma writes the report and cites evidence ids. Then guard.py judges each complaint:
cites nothing → whisper: "cites no measurement"
cites an id that doesn't exist → whisper: "cites E9, which was never measured"
complains about something unmeasurable → whisper: "'other' isn't something the browser can measure"
cites evidence of another kind → whisper: "cites E1 (horizontal_scroll), which doesn't show small_tap_target"
otherwise → haunting, with the measurement attached
The model never decides what counts as proof. It can only point at it.
Making a 2-billion-parameter model behave
Everything ran on Gemma 4 E2B through Ollama on my laptop, which has no GPU. Each call takes about 13 seconds, so a ghost's visit takes three to twelve minutes. Small models don't follow formats strictly, so most of my work went into meeting Gemma halfway:
- Gemma has no system role, so the instructions are folded into the user turn.
- It sometimes nested the action (
{"action": {"action": "click", "target": 3}}), used the action as a key ({"fill": "4"}), or wrote"click [7]". Onenormalize()function accepts all three. - When I reused the browsing prompt for the final report, it answered with another browsing step. The report now gets its own persona-only prompt.
- It quoted whole evidence lines (
"E1 goal_not_completed on /order: ...") instead of"E1". The guard now pulls the ids out of whatever it quotes.
The eval, and what it caught
The repo ships Marigold Bakery, a tiny Día de los Muertos shop with two modes. broken has seven everyday problems: a render-blocking slow script, a fixed 980px layout, an email field with only placeholder text, 16px quantity buttons, a "Place order" that is really a clickable div, 11px body text, and light grey text on white. fixed has none of them.
eval/run_eval.py sends the ghosts to both sites and commits every raw run.json. eval/score.py re-scores those files offline, re-running the guard on Gemma's raw complaints, so anyone can check the numbers without a model. "Gemma alone" counts everything Gemma complained about. "Gemma + guard" counts only proven hauntings.
| Run | What I changed first | Planted found (alone / guard) | False alarms (alone / guard) |
|---|---|---|---|
| 1 | nothing | 7 / 7 of 7 | 3 / 1 |
| 2 | fixed the guard, the bakery, and Tomás's hearing | 7 / 7 of 7 | 5 / 0 |
| 3 | Tomás hears page reloads and dropdowns | 7 / 7 of 7 | 4 / 0 |
| 4 | Tomás gets twice the steps (Tomás only) | 2 / 2 of 2 | 0 / 0 |
Every planted problem was found in every run, and across all runs the guard let through exactly one complaint about a problem I hadn't planted. That complaint was right.
Marisol found a bug I didn't plant. In run 1, on the "fixed" site, she complained that "Back to the breads" was too small for her thumb, and cited a measurement: 130×19px on the thank-you page. My own tests never visited that page. I added it to the tests, watched them fail with her exact finding, and fixed the link.
The guard had a bug too. Tomás honestly reported that he never finished, but quoted the whole evidence line, and the guard rejected it as unmeasured. Because score.py re-runs the guard on saved complaints, I could fix it and re-score run 1 without calling the model again.
Tomás couldn't hear. He ran out of steps on both sites in runs 1 to 3, even the fixed one, so I read his steps. After a page load nothing had focus, so his first type went nowhere. Pressing Enter on a link to the current page silently reloaded it. Pressing Space on a dropdown opened a headless popup that swallowed every later key. A real screen reader announces all of that, and my ghost heard none of it. Once he could hear focus changes, reloads and dropdowns, and got twice the steps (every Tab is a step), he ordered on the fixed site in 13 key presses and failed on the broken one for exactly the planted reason.
No ghost ever claimed success without reaching the confirmation page.
This is one small model on one small site, so read it as a sanity check, not a benchmark. The point it makes is narrow but useful: in each full run Gemma alone raised 3 to 5 false alarms, and the guard kept every real finding while dropping all of those false alarms.
A ghost on every push
The same check runs in GitHub Actions. The workflow runs the tests, installs Ollama on the runner, pulls Gemma 4 E2B, and sends Abuela Rosa to both bakeries. The broken one has to be haunted (--fail-on-haunting exits with code 3) and the fixed one has to rest in peace. Her report goes into the job summary. A run takes 7 to 10 minutes on the runner's CPU, and it needs no API key and no secret, because the model is just a download. Pointing it at your own site is one line:
ghost-visitors haunt http://127.0.0.1:3000 --ghosts rosa --fail-on-haunting --summary "$GITHUB_STEP_SUMMARY"
The session behind it
I built this with an AI coding assistant, and the session is saved below. Here is the part where the eval runs caught Marisol's bug, the guard's bug, and Tomás's deafness:
I'm checking both on the fixed site and found all 7 matches, with Gemma alone flagging 4 complaints and the guard flagging 1. Looking at that guarded false alarm, it seems Marisol found something real on the fixed site, so it should count as kept rather than planted or incomplete.
Tracing it down, I find the fixed thanks page has a "Back to the breads" link sitting as a plain inline anchor outside any card or header, giving it a small height under 24px that gets flagged as a small tap target. That raises the question of whether my check should account for the WCAG 2.5.8 spacing exception, where an undersized target still passes if a 24px circle around it doesn't overlap other targets — and I'm now working through whether the broken +/- buttons with their 8px gaps would actually pass …
Marisol's complaint checks out — the fixed site really does have a tiny 130x19px link, so that's genuine and not something I introduced. Tomás's tip also reveals a real bug: the guard is too strict when parsing evidence ids, since Gemma cited the full line instead of just the id, so I need to fix it to extract the E\d+ pattern from cited strings.
That parsing fix means scoring needs to change too — score.py should reconstruct the evidence log from raw complaints and re-run the judge, rather than relying on cached hauntings from run.json, so rescoring without the model stays meaningful. After the fix, Tomás's goal_not_completed complaint would correctly stay (it's honest — he genuinely didn't finish, not a site defect), while Rosa's fix confirms the guard correctly drops Gemma's …
I need to fix the id parsing so it extracts every E-number token from each cited string, keeping raw strings as "never measured" when no id is found. I'm writing a helper function that scans each cited item and pulls out matching identifiers into a list.
I also realize score.py should re-judge from raw complaints using evidence so re-scoring reflects the current guard, which means I need to check the EvidenceLog API in browser.py to reconstruct it properly.
I'll update the guard to extract E<number> ids from the cited text, and update eval/score.py to re-run the guard on saved raw complaints so guard changes can be re-scored without re-running the model.
I should commit the run-1 results now, before the guard fix, since that summary reflects the original scoring.
git add eval/results; git commit -q -m "Add first Gemma 4 E2B eval run (raw run.json per site plus summary)"; .\.venv\Scripts\python -m pytest -q tests/test_guard.py 2>&1 | Select-Object -Last 3
git add ghost_visitors/guard.py tests/test_guard.py; git commit -q -m "Guard: pull evidence ids out of quoted evidence lines"; git log --oneline -3
Next, I'm making score.py re-judge the saved raw complaints against the saved evidence with the current guard, instead of trusting the hauntings recorded at run time.
I need score.py to re-judge directly from the raw complaints, so I'm rebuilding the EvidenceLog by including every kind present and constructing items straight from the evidence dicts.
Updating the score test so it feeds real evidence ids and raw complaints with citations through the guard:
.\.venv\Scripts\python -m pytest -q tests/test_score.py 2>&1 | Select-Object -Last 3; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\score.py eval\results\gemma4-e2b\run-1 | Select-Object -First 10
The exit code -1 is just from truncating the pipe with Select-Object -First, that's fine. I'm reconsidering the "Complaints about the fixed site" row since it conflates goal_not_completed with real site problems — the former reflects a model navigation failure rather than an actual site defect. I'll keep the row as is but clarify the distinction in the README explanation.
I'll commit the scorer and the rescored summary as separate commits, since the rescored summary represents an actual result change: first the scorer fix, then "Re-score run 1 with the fixed guard."
The offline re-score confirms the guard now keeps 2 complaints on the fixed site, including Tomás's honest "I never finished" that the old parser had incorrectly discarded. I'll commit the scorer and the re-scored summary …
git add eval/score.py tests/test_score.py; git commit -q -m "Score: re-run the guard on saved raw complaints"; git add eval/results; git commit -q -m "Re-score run 1 with the evidence-id fix"; git log --oneline -2
Now I'll fix the "Back to the breads" link Marisol found. Then I'll look at why Tomás keeps getting lost on the order page in both runs.
Now I need to fix the demo app's thanks link in fixed mode and check Tomás's perception issue, so I'm looking at the thanks page and its describe() behavior in the browser.
I'm thinking about fixing the thanks link's tap target by giving it proper padding in fixed mode, while leaving the broken mode's small target as-is since that's already flagged as an issue there.
For Tomás's perception, I need him to announce the focused control like a screen reader would—reporting its role and accessible name, or nothing if the element lacks a ghost ID. I'm also puzzling over an odd focus jump in his fixed-mode run where typing into the select field seemed to skip ahead to the "Order" link instead of following the expected tab order, so I want to check the actual run data to understand what happened before implementing the announce feature and testing it on a fresh run.
The fixed-site test only walked / and /order, which is why I missed the thank-you page. I'm adding /thanks to the walk.
I see the test only walks / and /order, which explains why /thanks got missed — I need to add /thanks?name=... to the walk paths. With that fix the test should pass, and since the thanks link is padded consistently in both versions and the small-tap-target issue is already deduped by kind and page on /order, the broken expectations shouldn't change.
git stash push demo_site/app.py -q; .\.venv\Scripts\python -m pytest -q tests/test_browser.py -k fixed_bakery_measures_clean 2>&1 | Select-String "passed|failed|Back"; git stash pop -q; .\.venv\Scripts\python -m pytest -q tests/test_browser.py 2>&1 | Select-Object -Last 2
The new test fails on the old demo with exactly Marisol's finding, and passes after the fix. Committing:
git add demo_site/app.py tests/test_browser.py; git commit -q -m "Fixed bakery: give the thank-you page's back link a 24px+ tap target (found by Marisol)"; git log --oneline -1
Next, Tomás. In both runs he looped back to the header's "Order" link and reloaded the page. I'll check how keyboard typing and focus reporting work in act.
Now I'm looking at Tomás's run-1 steps to trace where the focus sequence went wrong — checking whether Tab after selecting the item actually moved to the name field, then email, rather than jumping straight to "Place order" as he described.
I notice that pressing Enter on the email field triggered a page reload at the /order URL, which suggests the focus was actually on the Order link, not the email field. I'm wondering if typing text into the bread select dropdown caused unexpected focus behavior, so I want to check how the "type" action is implemented for select elements.
I'm realizing the core problem: after navigation, focus resets to nothing, so the typing step actually went nowhere, and the model failed to track that. The real fix is to mimic screen reader behavior—announce which control currently has focus, and handle the case where typing happens with no element focused.
I'll implement this by checking document.activeElement before each keyboard action, matching it against the scanned controls to report either where typed text actually landed or that it went nowhere, and similarly reporting where focus moves after Tab/Enter presses. I'll write a helper that resolves the currently focused element into a readable description like "[3] combobox 'Bread'".
I found the issue: after each page reload, focus resets to the page itself, so Tomás's typing had …
Adding a test for what Tomás now hears:
I should add a test confirming that ghost typing with nothing focused doesn't deliver text anywhere, and that Tab still announces properly. The existing fixed-form submission test should still work since it reuses the prior scan, so I just need to add the new assertions alongside it.
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 15
I notice an unused variable assignment where the result is never used afterward, so I'll just call the method directly without storing it.
git add ghost_visitors/browser.py tests/test_browser.py; git commit -q -m "Keyboard ghost hears focus changes and typing that lands nowhere, like a screen reader"; git log --oneline -1
Next, run_eval.py gets a --label flag so a second run lands next to run 1 instead of overwriting it.
I need run_eval to support a --label flag so a second run saves to a separate folder rather than overwriting, so I'll give it a sensible default like "latest" and pass "run-2" explicitly. I should also check whether old background processes on the bakery and web UI ports are still running stale code, since the web UI loads visitor data at startup.
git add eval/run_eval.py; git commit -q -m "Eval: keep each run in its own labelled folder"; git push -q origin main 2>&1; git log --oneline -1; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --label run-2 --max-steps 16
Eval run 2 is running in the background and should take about 25 minutes. Meanwhile I'll update the README and fetch the challenge's submission template for the DEV post.
While the process runs, I should draft the README eval section and the DEV post, and check what's currently there. I also need to pull challenge details for the Week 4 template, so I'll look into using get_challenge_details via DevRelay and check its schema.
I need the challenge id first, so I'll call get_challenges to look that up.
The template link isn't in the full details I have. I should check the challenge page itself since dev.to posts often use a prefill URL for the template - let me search back through what I've already fetched for that.
The challenge page doesn't show the template text itself, so I'll copy the section structure from existing #hf26challenge submissions.
The status text is ambiguous since it shows both "Live" and "Ended," but given today's date it should still be live. For tags, DEV only allows four, so I'm narrowing down to devchallenge, hf26challenge, and then deciding between ai/opensource or a gemma-specific tag for the Best Use of Gemma category.
I should find a reference structure instead of guessing the template body—let me search DEV for existing hf26challenge submissions to see how others formatted their posts.
The template sections are: What I Built, Demo, Code, How I Built It, Why Does Open Innovation Matter?, plus a Prize Categories section for category entries. Checking on eval run 2:
I'm mapping out the template structure—intro, What I Built, Demo, Code, How I Built It, and the Open Innovation question, with Prize Categories as optional. Now I want to check on the eval progress.
I'm not sure the push succeeded, so I should check git status and the log line to confirm. While waiting, I'll draft the README eval section with placeholder numbers and start the post, after reading through the README fully.
The local commits are pushed. While run 2 works, I'm drafting the DEV post outside the repo and running a quick DEV search for related community posts.
I need to fix the README claim that complaints in fixed mode are always false alarms, since that's no longer accurate given recent findings — I'll update that after run 2. I also want to grab screenshots of the report and web UI for the README/post, planning to capture them via a quick headless Playwright script after that run.
I'll draft the DEV post outside the repo, in a separate local file, while waiting on run 2 numbers. Key facts to cover: the Gemma 4 E2B/Ollama CPU setup and timing, the run 1 results and bugs found, Tomás's limitations, the run 2 results, and honest caveats about the model's size and the heuristic nature of the …
That interlude works well. Now I should hold off on writing the post draft until I get the run 2 numbers from the eval, so I'll wait around ten minutes for those results.
While run 2 works, I'll write the post with placeholders for its numbers, saved outside the repo.
I'll draft the post now with placeholder XX values for run 2's numbers, since writing it is the bulk of the work anyway. I still need to grab screenshots of the eval report and web UI after run 2 finishes to include in the doc.
For the title, I'm leaning toward something punchy about "ghost visitors" — people who left the site without engaging, voiced by Gemma and proven real through code. I want to write it carefully, matching the user's plain-sentence style and avoiding overusing em dashes.
Tomás pressing Enter on the "Breads" link just reloads the home page and resets focus, since the href points back to "/". The model seems confused here, believing it tabbed to "Order" and pressed Enter there, when really focus was still on Breads after just one tab — a misread of where the cursor actually landed.
I think the real fix is a stronger prompt cue reminding the model to check "Focus is on" right before pressing Enter, since Enter activates exactly that control, not whatever the model assumes. Now I want to check how Marisol's run went, since I recall the "0 proven, 2 whispers" link issue got fixed there.
I found the bug: pressing Space on the select opens its dropdown in headless Chromium, and the subsequent Tab doesn't move focus because the popup seems to absorb the keystroke. In real Chrome, Tab would normally close the listbox and advance focus, so this looks like a headless-specific quirk where the open popup traps keyboard input.
The honest fix is to treat this as a harness limitation rather than a site bug: when the focused control is a combobox and the action is Space or Enter, instead of actually pressing it, report that it's a dropdown and the user should type an option's name to select it, mirroring rough screen-reader behavior. I also want to detect page reloads from Enter by setting a marker on the window before the keypress and checking afterward if it's gone, and I should …
Tomás's count stays at 0 since he ran out of steps rather than flagging goal_not_completed. For run 2, Gemma alone produced 5 false alarms while the guard had none. I'll commit these run-2 results, then fix the space-on-select and reload notice issues before committing again, possibly running a third pass focused on Tom.
Checking the time — it's Oct 28, deadline Oct 31 — there's room for a run 3 (~25 min). Tomás has never completed a run, so a run where he finally completes the fixed site while still failing the broken one would be the clearest validation of the core signal. That seems worth doing. Committing run-2 now.
Run 2 results are in: the broken site caught all 7 planted problems, and on the fixed site Gemma raised 5 false complaints, all correctly filtered out by the guard. …
git add eval/results/gemma4-e2b/run-2; git commit -q -m "Add Gemma 4 E2B eval run 2 (after the guard, demo and focus fixes)"; git log --oneline -1
I'm thinking about the keyboard branch logic for comboboxes—if a select is focused and the user presses space or enter, maybe I shouldn't actually trigger a press and instead return guidance suggesting they type an option name and tab away. I need to check whether Enter actually submits a form when focus is on a select element in Chrome, since that could change the right behavior here.
For detecting an unwanted reload, I'm planning to set a marker in the page before pressing a key, then after pressing, check if the URL is unchanged but the marker is gone, which would mean the page reloaded and focus landed back on the document itself. I should wait for the load state to settle before evaluating that marker, since checking too early while navigation is in-flight could throw an error.
.\.venv\Scripts\python -m pytest -q tests/test_browser.py 2>&1 | Select-Object -Last 15
The message is right; only my test's expected text was wrong (the dropdown reports its label, not its value).
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 3
Splitting into separate commits seems unnecessarily cumbersome given the non-interactive setup, so I'll just commit both keyboard feedback changes together as one atomic commit about hearing page reloads and typing into dropdowns instead of opening them. I also want to double check the prompt cue about adding guidance to KEYBOARD_ACTIONS noting that enter activates exactly the control described in "Focus is on."
git add ghost_visitors/browser.py tests/test_browser.py; git commit -q -m "Keyboard ghost: hear page reloads, and type into dropdowns instead of opening them"; git log --oneline -1
I'm also adding one prompt line for Tomás. Run 2 showed him pressing Enter on "Breads" while believing focus was on "Order".
I see the model pressed Enter while focus was actually on Breads, not Order—it assumed focus matched the control it intended to reach. I should add a clarifying note: Enter activates whatever "Focus is on" currently names, not the control being navigated toward. I'll commit this as a prompt tweak and then run trial 3.
.\.venv\Scripts\python -m pytest -q tests/test_agent.py 2>&1 | Select-Object -Last 2; git add ghost_visitors/agent.py; git commit -q -m "Remind the keyboard ghost that Enter acts on what has focus now"; git push -q origin main 2>&1; git log --oneline -1; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --label run-3 --max-steps 16
Run 3 has started. While it runs, I'm taking screenshots of a real report and the web UI for the README and the post.
While run 3 is in progress, I'll grab screenshots of the report and web UI — the UI on 8765 has stale code but it's probably fine to restart it for this. I need to locate the broken run-2 report directory and use a quick temporary Playwright script to screenshot the Rosa or Marisol card, clipping the view since a full-page shot could be too tall.
Clipping to roughly 1400px at 1200px width should work, saving as docs/report.png and docs/web-ui.png. Running Playwright alongside the eval should be safe aside from some CPU contention, and the web UI's Ollama ping shouldn't cause issues.
I'm noting that the broken run (20261028-005813, started around 00:58) involves a screenshot script that writes a temp file to %TEMP%.
The report screenshot works well. For the post I also want one that shows whispers being set aside, which is the honesty mechanic. Marisol's card from the run-2 fixed-site report has two.
The report image looks good overall. I'm noting a few small issues to flag: the clip might be cutting off thumbnails at the bottom so maybe adjusting the clip point would help, the horizontal scroll measurement on /thanks looks fine, and there's a slight misalignment in the "Ghosts" label in the web UI worth checking.
I'd also like to capture a screenshot showing the whisper-setting-aside mechanic — the fixed report has Marisol's card with two whispers, so I'll screenshot that card cropped to the whispers section to illustrate the honesty feature.
That screenshot tells the story on its own. On the fixed site, Gemma's Marisol says "this site is a nightmare", and the guard shows nothing measured backs it. Committing the screenshots:
That Gemma comment about the site being a nightmare makes a good hero image for the post, though I should guard against overstating it since nothing's proven yet. I'll commit the docs images now with a message like "Add report and web UI screenshots," then update the README with images and an eval section, waiting on run 3 to finalize the eval numbers.
git add docs/report.png docs/whispers.png docs/web-ui.png; git commit -q -m "Add screenshots of a haunting report, guarded whispers and the web UI"; git log --oneline -1
And the whole build, from "can I enter this challenge?" to the CI ghost (tool outputs left out, local paths scrubbed):
Awesome, so can I modify this project in some ways to fit and win prize(s) at https://dev.to/challenges/hacktoberfest-week4-2026-10-26
I need to fetch the challenge details first. I should also keep in mind the GitLab hackathon repo can't be modified until Nov 16, so any changes need to go into a fork or separate repo instead.
Not by modifying Ghost Customers itself. A new open-model companion project, built this week, could fit the challenge very well, though.
Why the current project can't be entered:
- The AI at its core has to be open. The challenge requires open-weight models or open-source AI, and says "the open pieces should be what makes your project work". Ghost Customers runs on GitLab Duo, which is closed.
- It has to be a brand-new project. This Hacktoberfest series asks for new projects built during the challenge window, which runs October 26 to 31. Most of Ghost Customers was built before the window opened at 19:00 UTC on the 26th.
- You can't touch the GitLab repo. It's frozen until GitLab announces winners around November 16. Changing it to fit this challenge could cost you a …
please do not touch/edit ghost customers at all! here's a fresh repo: https://github.com/emarc99/ghost-visitors
hope it will not clash locally in this chat, maybe just create a sepate, new directory as well
I'll keep everything in a new folder next to Ghost Customers and won't touch that project at all. First I'll clone the repo and check what's available locally for running an open model.
I'll set up a new sibling directory for ghost-visitors, clone the repo there, and check the environment for Ollama, GPU, and Python before building. Let me run these checks in parallel.
No Ollama, no NVIDIA GPU, just 24GB RAM to work with, so CPU inference of a small Gemma model is feasible but likely slow — I'm not sure exactly which Gemma version is current, so I should check the Ollama library when relevant. Installing Ollama would require a system-level install, so I need to get the user's consent before doing that.
The empty repo is now cloned into ghost-visitors, separate from life-after-code. Your machine lacks Ollama and an NVIDIA GPU but has 24 GB RAM, enough to run a small Gemma model on CPU — checking the Gemma prize category requirements next.
Starting fresh since the rules require the repo to be created within the challenge window, I should credit the prior Ghost Customers idea in the submission. I'm weighing whether to suggest installing Ollama on Windows and pulling a smaller Gemma model for CPU-only use, though I'm concerned about speed with multi-step agent loops.
An alternative is serving Gemma through Google AI Studio's API, which still counts for the Gemma category but weakens the "why open matters" argument, so I'm thinking of supporting both options with a configurable endpoint. I should just ask the user concisely whether to install Ollama now and what model size they'd prefer.
The challenge rules confirm a brand-new repo will meet the window requirement, and I'll credit Ghost Customers as inspiration in the post. …
I should check past winners from weeks 1-3 of the Gemma category by searching DEV for winners announcements.
I want to check whether the winning entries actually used hosted or local setups, so I'll look at the Gemma category winner and the overall winner to see if they mention local tools like Ollama versus hosted ones like AI Studio.
Yes, hosted Gemma qualifies. The Gemma category rules say: "run it locally, fine-tune it, or serve it through Google Cloud or another provider". I checked the Week 2 winners to see what actually won:
- Week 2 overall winner, Deis by Muhammad Murtuza Hussain: it runs Gemma 4 locally through Ollama, or on the user's own free Google AI Studio key, with a second hosted Gemma as backup. When AI Studio went down for hours, the backup became a selling point in the post. Even so, the pitch led with "on your laptop, with the Wi-Fi off".
- **Week 2 Best Use of Gemma winner, …
yes!
and do atomic commits to github repo, when commiting changes.
I'll start the Ollama install in the background and plan the build while it runs.
I should plan this carefully and start installing Ollama in the background since it'll take a while.
I'm planning out the project structure for ghost-visitors in Python, considering Playwright for browser automation and sketching a module for defining personas with distinct backstories, device emulation settings, and interaction goals.
The agent loop caps at ~12 steps and ends with a done call reporting success or giving up with reason, each action validated against the observed page before executing. Core design principle: Gemma writes the haunted persona narrative but every claim must cite deterministic code-measured evidence (unnamed fields, tiny tap targets, horizontal scroll, slow load, keyboard traps) — unsupported claims get dropped. For the LLM client, I need an OpenAI-compatible chat wrapper that works against both local Ollama and AI Studio's Gemma endpoint, checking whether …
I recall that chromium-1148 maps to playwright 1.49.x, so I'll pin playwright==1.49.1 to reuse that browser version—its wheels are py3-none, so Python 3.13 compatibility isn't an issue. Now I'll set up a venv and install it.
python -m venv .venv; .\.venv\Scripts\python -m pip install -q --upgrade pip; .\.venv\Scripts\python -m pip install -q playwright==1.49.1 flask pytest 2>&1 | Select-Object -Last 3; .\.venv\Scripts\python -c "import playwright, flask; print('ok')"
The virtual environment is ready, and it reuses the Playwright browser already on your machine. While Ollama downloads, I'll write the repo scaffold.
While the download runs, I'm scaffolding the repo with a .gitignore, MIT license, pyproject.toml, and requirements.txt. For the demo site, I'm designing a "Marigold Bakery" theme around Día de los Muertos pan de muerto, building a small Flask app with routes to showcase fixed versus broken versions.
I'm planning the specific accessibility defects to plant in the broken mode: a fixed-width layout causing horizontal scroll on phones, an unlabeled email field, a non-focusable div acting as the submit button so keyboard users can't order, tiny tap targets for quantity controls, and low contrast plus undersized text to simulate a low-vision …
git add -A; git commit -q -m "Scaffold project: license, packaging and ignore rules"; git log --oneline
Now the demo site the ghosts will haunt: a small Día de los Muertos bakery with planted problems. It has a "broken" mode and a "fixed" mode, and those two modes become the evaluation target.
I'm designing the Marigold Bakery demo site as a Flask app with a mode parameter, building a home page with hero text and product cards linking to an order page, then starting on the order form with name and email fields, a pickup day selector, and a quantity input—deliberately including some accessibility issues like a placeholder-only email label.
I'm also planning the broken vs. fixed quantity buttons (tiny 16px targets vs. proper sizing), the submit action (a clickable div with onclick JS that keyboard users can't reach vs. a real submit button), a POST route to a thank-you confirmation page, a …
I'm checking the contrast values for both fixed and broken modes look right, and now I want to write a quick Flask test client test to verify the demo site before committing.
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 3
The import worked fine without a conftest since pytest's rootdir insertion handled the path, even without an init file. I'll commit this.
git add -A; git commit -q -m "Add Marigold Bakery demo site with broken and fixed modes"; git log --oneline -1; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 4
Next is the model client. It talks to any OpenAI-compatible endpoint: Ollama on your machine by default, or hosted Gemma.
I'm designing an LLM client class with a method for chat-based JSON responses, plus a JSON parser that strips code fences and balances braces to extract valid JSON, falling back to raw_decode if needed. I'm also noting that Ollama's OpenAI-compatible endpoint supports forcing a JSON object response format.
Since AI Studio's Gemma models don't support JSON response_format or system roles the way Ollama does, I'm making response_format conditional with a retry-without fallback on HTTP 400, and always merging the system prompt into the first user message for universal compatibility across backends. I also want to track call stats like token counts and latency from the …
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 3
git add -A; git commit -q -m "Add OpenAI-compatible model client that defaults to local Ollama"; git log --oneline -1; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 3
I'm starting to design the persona system, defining a Ghost data structure that captures each persona's identity, backstory, voice, and browser viewport settings like device scale and mobile flag.
Specifically, I'm sketching three ghosts—Marisol on a throttled mobile phone checking for slow loads and tiny tap targets, Tomas using a screen reader on desktop to catch unlabeled or unreachable controls, and Rosa browsing at 200% zoom to catch small text and contrast issues. I'm also working out concrete thresholds, like flagging loads over 3 seconds and touch targets under 24x24 CSS pixels, checking whether the site's header links would actually fail that test.
I realize header nav links aren't exempt the way inline sentence links are under WCAG 2.5.8, so I need to pad them to meet the size …
I'm adjusting the base CSS so header and card links have consistent inline-block padding across both modes.
That odd workaround for the focus line needs cleaning up—I should replace it with something more straightforward instead of that hacky character trick.
I'm sorting out the space key mapping so it displays properly, then planning Playwright tests that spin up a server in both modes and check evidence per ghost, accounting for slow-loading cases like Marisol's broken home scenario.
Now I'm walking through expected evidence for each mode and ghost, then double-checking the fixed version for false positives — verifying header and card text contrast, separator characters, and viewport widths to make sure nothing trips the detectors incorrectly.
For Marisol and Tomás, I'm confirming tap targets, aria-labels, and load times all pass in the fixed version, while verifying the broken version's scrollWidth genuinely exceeds the viewport. With everything checked, I'm ready to write the conftest and test_browser files.
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 25
I need to fix my tab count in the test — it should be 4 tabs to reach the name field (2 header links, then select, then name), not 2. After that I'll type Rosa, tab to email, type it, and hit enter. I'm also wondering if the URL-change check after Enter submission might run too early, before navigation actually starts, so I should add a short wait after the keypress to be safe.
.\.venv\Scripts\python -m pytest -q "tests/test_browser.py::test_each_ghost_measures_its_planted_problems[marisol]" -p no:logging 2>&1 | Select-String "assert|Extra|Missing|^E " | Select-Object -First 12
I'm realizing that without a meta viewport tag, mobile Chrome emulation with is_mobile defaults to a layout viewport of 980px, causing the browser to zoom out and shrink everything to fit — which explains why horizontal scroll isn't detected even though the page visually looks tiny and squeezed on real phones too.
.\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 4
I should split this into three separate commits: the demo site tweak padding the links to avoid target-size false positives, then the personas change, then the browser change.
git add demo_site/app.py; git commit -q -m "Give demo nav links a real tap area in both modes"; git add ghost_visitors/personas.py; git commit -q -m "Add the three ghosts: Marisol, Tomás and Abuela Rosa"; git add ghost_visitors/browser.py tests/conftest.py tests/test_browser.py; git commit -q -m "Add ghost bodies: device emulation, page scans and measured evidence"; git status --short; git log --o
The page measurements work: in tests, each ghost finds exactly its planted problems on the broken bakery and none on the fixed one. Next is the agent loop and the guard that checks Gemma's claims.
While the Ollama download continues, I'm moving on to building the agent loop, defining issue kinds like slow load, horizontal scroll, small tap targets, and contrast problems, along with pointer and keyboard action sets and the system prompt for the agent.
I'm sketching out the Visit dataclass to track a session's steps, outcome, evidence, report, and usage stats, plus a compact history format for prompting and a "fright" score per step. The step loop itself navigates to the URL, observes the page, screenshots each step, asks the model for a decision, normalizes and executes the action, and …
I need to make sure goal_not_completed events always bypass the lens filter in EvidenceLog.add, so I'm introducing an ALWAYS tuple containing that kind and updating the condition check accordingly. Then I'll write unit tests for the guard logic and an agent test using a scripted fake LLM that exposes chat_json and usage.
.\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 30
The guessed control ids worked out correctly, matching name(4), email(5), and submit(10) against the full list. Now I should remove that unnecessary _browser helper and commit the guard and agent changes as separate commits.
$f='tests/test_agent.py'; (Get-Content $f -Raw).Replace('haunt(_browser(browser),','haunt(browser,').Replace("`n`n`ndef _browser(browser):`n return browser`n","`n") | Set-Content -NoNewline $f; Select-String -Path $f -Pattern "_browser"; .\.venv\Scripts\python -m pytest -q -p no:logging tests/test_agent.py 2>&1 | Select-Object -Last 1
git add ghost_visitors/guard.py tests/test_guard.py; git commit -q -m "Add the guard: complaints must cite measured evidence of the same kind"; git add -A; git commit -q -m "Add the haunting loop: Gemma decides as the persona, then reports"; git log --oneline -2; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 3
Next are the report and the run orchestrator. The report is a single HTML page per run, styled for the theme: proven complaints are "hauntings", unproven ones are "whispers".
That guard commit including the browser.py EVERY_GHOST change is fine since the agent needs it. The Ollama download isn't worth checking right now, so I'll move on to report.py for the HTML output, then the run.py orchestrator that runs ghosts sequentially.
I'm defining run_haunting to launch a browser, visit each ghost sequentially collecting results, and write out run.json plus a rendered index.html report, with an on_event callback for progress updates in the web UI. For the report itself I'm designing a dark night theme with marigold accents and per-ghost sections showing outcome badges like "Rested in peace" …
I'm planning a test for run_haunting using a scripted LLM-like object with model, base_url, and usage attributes, verifying the generated index.html contains the expected phrase and properly escapes a malicious verdict like a script tag.
.\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 15
I realize the session-scoped sync_playwright fixture clashes with a second instance inside run_haunting. Instead of spawning another, I should let run_haunting accept an optional browser parameter, reusing the existing one when provided—which also helps eval share a browser cleanly.
.\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 15
git add ghost_visitors/report.py; git commit -q -m "Render haunting reports as a self-contained HTML page"; git add ghost_visitors/run.py tests/test_run.py; git commit -q -m "Add run orchestration that writes run.json and the report"; git status --short; git log --oneline -2; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 3
That download has been stuck for over 20 minutes now, so I should check the terminal's running_for metadata and see if the file is actually growing. Winget downloads go to %TEMP%\WinGet\, so I'll check there.
Ollama's installer is about 1.6 GB and still downloading. Meanwhile I'll write the command-line interface.
The file's staying at 1585MB across checks even though the write timestamp keeps updating, which suggests it's preallocated rather than actively growing — that's consistent with the Ollama installer, which is roughly 1.6GB in size.
While that finishes, I'll sketch out the CLI structure: a haunt command for running visits against a URL with ghost/goal/step options, a web command for the UI server, a bakery command to launch the demo site, and a ghosts listing command, plus a __main__.py to support module invocation. I want console progress output showing things like a ghost entering and each step's action with fright level and thought. I'm also planning a preflight check …
I'm worried emoji characters could cause UnicodeEncodeError on Windows consoles with non-UTF8 encodings, so I should make stdout reconfigure to replace errors at startup. I also need to think about testing the CLI's model-checking logic against a fake server.
.\.venv\Scripts\python -m pytest -q -p no:logging tests/test_cli.py 2>&1 | Select-Object -Last 8; .\.venv\Scripts\python -m ghost_visitors ghosts
I notice the CLI module imports .web which doesn't exist yet, but that's only used by the web command, so it's fine to include the llm ping preflight check in this same CLI commit.
git add -A; git commit -q -m "Add the ghost-visitors CLI with a model preflight check"; git log --oneline -1; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 4
Now the local web UI, which wraps the same run code so the demo can be shown in a browser.
I'm designing a Flask web UI: a home page with a form for starting a haunting run plus model status and past reports, a POST handler that kicks off the haunting process in a background thread and redirects to a job page, and a live job log page that auto-refreshes to show progress.
There should also be a safe route for serving generated report files, and I want to limit things to one job running at a time since the model is CPU-bound. I need to make sure Playwright's sync API works fine in a new thread since there's no existing asyncio loop to conflict with, then write this all into web.py matching the existing report's style.
The send_from_directory call already sanitizes path traversal through the run name. I'm planning tests using a fake runner and fake LLM factory pointing to an unreachable model server, checking that the home page shows "Model not ready" and that POST /haunt works with the fake runner.
I'll have the fake runner write a folder with run.json and index.html, poll the job status until finished, fetch the report, and confirm a bad URL returns a 400.
.\.venv\Scripts\python -m pytest -q -p no:logging tests/test_web.py 2>&1 | Select-Object -Last 15
git add -A; git commit -q -m "Add a local web UI to start hauntings and browse reports"; git log --oneline -1; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 4; Get-Command ollama -ErrorAction SilentlyContinue; Test-Path "$env:LOCALAPPDATA\Programs\Ollama\ollama.exe"
The file preallocation is taking longer than expected, so I'll use the wait time to write the evaluation script. I'm sketching out eval/run_eval.py to spin up broken and fixed bakery servers locally, then run the haunting process across each mode and ghost combination, capturing per-visit outcomes with a step limit.
I need to define ground truth carefully: planted complaint kinds per ghost lens (seven planted kinds across three ghosts), noting that Tomás specifically should trigger goal_not_completed on the broken site since his issue blocks submission entirely, while Marisol and Rosa's issues are more like annoyances that don't necessarily prevent completion. Then I'm working through the metrics — detection rates for raw versus guarded complaints, false alarm counts on the fixed site, …
I should make run_eval also write summary.json for consistency with score()'s main behavior, since right now it only writes summary.md. The usage accumulation and varying port URLs in run.json seem fine as-is. I should also add a quick tests/test_score.py using a synthetic run.json, importing via sys.path since the eval directory isn't a package.
.\.venv\Scripts\python -m pytest -q -p no:logging tests/test_score.py 2>&1 | Select-Object -Last 8
git add -A; git commit -q -m "Add an eval: broken vs fixed bakery, Gemma alone vs Gemma with the guard"; git log --oneline -1; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416758.txt -Tail 4
I need to wait for the download to finish, so I should check the AwaitShell schema to see if it supports a pattern parameter.
While I wait for Ollama, I can draft the README structure now and leave the eval numbers section to fill in once results come in, since everything else depends on that.
With a 13 minute download in progress, I can't easily check progress since the file is preallocated. I'll instead try checking network adapter statistics to measure the byte delta over time.
At this download speed, the current file should finish soon, but then the next model, gemma e4b, is larger and could take over an hour given CPU constraints, while the smaller e2b variant would be considerably faster to pull and run.
Ollama is installed and running. Next I'm checking the Gemma 4 download sizes, since your connection is about 1.5 MB/s.
I should check what gemma4 tags are actually available, like e2b, and look at their manifest sizes to compare the e2b and e4b variants.
Given the size difference, e2b should pull faster and run roughly twice as fast on CPU, and since it performed well in prior comparisons, I'll set it as the default model and kick off the pull in the background.
Gemma 4 E2B (4.3 GB, ~45 min to download) runs twice as fast on CPU than E4B and was used by the last Best Use of Gemma winner, so I'll pull it and set it as the default.
$env:Path = [Environment]::GetEnvironmentVariable('Path','User') + ';' + [Environment]::GetEnvironmentVariable('Path','Machine'); ollama pull gemma4:e2b 2>&1
foreach ($f in 'ghost_visitors\cli.py','ghost_visitors\llm.py','eval\run_eval.py','README.md') { $t = [IO.File]::ReadAllText("$PWD\$f"); [IO.File]::WriteAllText("$PWD\$f", $t.Replace('gemma4:e4b','gemma4:e2b'), (New-Object Text.UTF8Encoding $false)) }; git diff --stat; .\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 2
I should split this into two commits - first the model default change in the code, then the README update separately since it also mentions e2b.
git add ghost_visitors eval; git commit -q -m "Default to Gemma 4 E2B, which runs well on a CPU"; git add README.md; git commit -q -m "Write the README: ghosts, how it works, setup and eval"; git log --oneline -3; git push -u origin main 2>&1 | Select-Object -Last 2
All 17 commits are pushed to https://github.com/emarc99/ghost-visitors, and the 38 tests pass. The model download is the bottleneck now, so I'll check its progress.
With 17 commits pushed and the model download taking about 45 minutes, I can use this time to prepare other things, like getting a real report screenshot for the README. Let me check on the download progress.
At 3.6 MB/s that's roughly 16 minutes to go, so I'll just wait it out — nothing else essential to do, and the DEV post skeleton needs the results before I can draft it anyway.
The output got cut off, so I should check the available models by running ollama list separately.
The download is stuck at 3.26GB of 3.5GB for the largest layer with 0 MB/s throughput, so it might be stalled rather than actively transferring. I should check if the shell process is still alive and inspect the partial files.
The download still has some chunk files showing partial sizes, so I'm not sure if it's genuinely stalled or just retrying stragglers near the end. Given the transfer rate, it should have wrapped up around 11:38, so I'll wait a bit longer and check again.
The download's still going at 2.9MB/s but the full 3.5GB should've finished by now, so I want to check the chunk JSON to see actual completion progress.
About 1.8 of 3.3 GB is down, roughly 10 more minutes.
Download is at 1.83 of 3.26GB after about 30 minutes, averaging roughly 1MB/s, but at the current faster pace of ~2.9MB/s the remaining 1.43GB should take about 8.5 minutes, plus a bit more for other small layers. I'll wait around 10 minutes for it to finish.
The main model layer is done. A second layer of about 0.9 GB is half finished.
The second layer, likely the vision/audio projector, is about halfway downloaded at 0.92GB, so I'm estimating another 3-5 minutes before it finishes.
Gemma 4 E2B is downloaded. Next is a first real run: Abuela Rosa on the broken bakery, to measure speed on your CPU.
Time for a smoke test: I'll run one ghost on the broken bakery with max-steps set to 6 to measure speed, starting the bakery server in the background before running haunt.
About 13 seconds per call, which seems reasonable. I'm noticing the target field is coming back None, so the model must be putting that value under a different key than expected—I should inspect the raw decision output and log it into the step action to see what key it's actually using.
The run works mechanically at ~13s per Gemma call, but Rosa never clicks since Gemma isn't returning the control number in the expected field. I'll check what it actually sent.
I notice Gemma nests the action as an object rather than a flat value, which my normalize function is mishandling by stringifying the dict instead of merging its fields—I need to fix that, and also handle string-form actions like "click [3]". I'm also wondering why the report came back with no complaints, since Rosa should have flagged small text and low contrast issues on "/" where evidence clearly exists.
The report is coming back empty, so Gemma likely nested it under another key like a wrapper object. I should write a helper that unwraps nested dictionaries by searching for the expected keys at the top level or one or two levels deep, and also save the raw report output on the visit object for debugging transparency.
I also need to normalize the action format, handling cases where it's a nested dict, a structured action/target pair, or a plain string like "click 3".
Found it—Gemma nests its answer one level deep, e.g. {"action": {"action": "click", "target": 3}}, and does the same for the report. I'll update the parser to accept both shapes and save the raw report for auditability.
I need to make sure the normalize function strips "thought" keys from nested fields too, not just the top level, so I should pop thought/fright after merging rather than before.
I should also add tests covering unwrap and normalize for nested cases, and investigate why the report came back empty to understand what Gemma actually returned.
.\.venv\Scripts\python -m pytest -q -p no:logging tests/test_agent.py 2>&1 | Select-Object -Last 3
git add -A; git commit -q -m "Accept nested actions and reports from small models; keep the raw report"; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python -m ghost_visitors haunt http://127.0.0.1:8001 --ghosts rosa --max-steps 10 --goal "Order some bread for the Day of the Dead, as you would in real life." 2>&1
I realize Rosa never filled in the required email field, so clicking "Place order" triggers HTML validation that silently blocks submission—something the ghost wouldn't perceive. I need to add a check after actions: if the URL hasn't changed and the form is invalid, surface that nothing happened along with the validation message, perhaps by inspecting invalid fields on the page after a submit attempt.
I also notice she accidentally typed the email into the name field, which is a minor model confusion but not critical. I still need to check why step 2's target is None again, and I need to look at the raw report since it's showing zero complaints despite these issues.
Rosa now gets through the order form, but two issues remain: the required email field silently blocks submission since …
I'm noticing two issues: the action shape uses the action name itself as a key with the target as its value, so I need to handle that pattern when no explicit kind is given. Also, Gemma returned a step decision instead of a report because the step-format system prompt instructions are bleeding into the report call — I need a separate, simpler system prompt for reports that's just persona identity without action instructions.
I found two issues: Gemma sometimes outputs the action name as a key like {"fill": "4", "text": ...}, and the report request mistakenly reused the browsing instructions, causing another click instead of a report. I'll fix this by giving the report call its own persona-only instructions.
The browser now also needs to tell the ghost when a form refuses to submit, the way a real user sees the "Please fill out this field" bubble. I'll capture the browser's invalid events directly.
I'm thinking through how to surface validation feedback after a browser action—checking for invalid form fields and their messages, but only flagging them when the action was actually a submit attempt, so I don't falsely report fields that are simply empty and required but not yet submitted.
Rather than guessing which clicks count as submits, I realize I can inject a listener for the native 'invalid' event, which only fires on real submission attempts, and store those into a window-level array I can read and clear right after the action completes. That's the cleaner approach using an init …
.\.venv\Scripts\python -m pytest -q -p no:logging 2>&1 | Select-Object -Last 6
Good, the fix correctly flags the missing name field as the first invalid entry and the test passes. I'll split the work into two atomic commits: one for the agent's parsing and report prompt changes, and another for the browser's blocked submit behavior.
git add ghost_visitors/agent.py tests/test_agent.py; git commit -q -m "Ask for the report with persona-only instructions; accept action-as-key replies"; git add ghost_visitors/browser.py tests/test_browser.py; git commit -q -m "Tell the ghost when the browser blocks a form submit"; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python -m ghost_visitors haunt http://127.0.0.1:8001 --ghosts rosa --m
Rosa ordered her pan de muerto in 6 steps, and her report has 2 proven hauntings: the small text and the low contrast. I'll open the report in the web UI to see how it looks.
Rosa confirmed 2 proven hauntings, so now I want to view the report visually—I'll start the web UI on port 8765 to serve the reports and take a screenshot.
The accessibility snapshot seems fine despite the alt text quirk, though I notice the "view" links are repetitive and could use clearer aria-labels like "Screenshot of step N" — a minor fix for later. Now I'll move on to running the full test.
Working out the eval setup for all three ghosts across both sites: Rosa should take about 3 minutes, Marisol's slow network adds delay, and Tomás's keyboard navigation needs roughly a dozen steps at ~13 seconds each, so maybe 3-4 minutes — totaling around 20 minutes, which I'll run in the background. Tracing through Tomás's path, I count he needs about 11 tab/type/enter actions minimum to complete an order starting from home, so I'll set the eval's max-steps to 16 to give him a fair shot at success before the fixed run measures results.
The …
$env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --max-steps 16 2>&1
While the evaluation runs, I'll fix a small accessibility issue in our own report: every screenshot link just says "view", which a screen reader user like Tomás couldn't tell apart.
I should also fix accessibility on the report's "view" links by adding descriptive aria-labels for each screenshot step, since the image links already have alt text. I'll make this small change in report.py and commit it later, since it doesn't affect the current eval run since modules are already loaded.
.\.venv\Scripts\python -m pytest -q -p no:logging tests/test_run.py 2>&1 | Select-Object -Last 1; git add ghost_visitors/report.py; git commit -q -m "Name screenshot links in the report for screen reader users"; git log --oneline -1
Things are progressing well — Marisol succeeded with 3 proven, while Tomás ran out of steps as expected since the broken site blocks submission anyway. The Werkzeug logs are just noise, nothing concerning. I'll wait for the remaining run to finish, roughly 10 more minutes.
Marisol's order went through with 3 confirmed hauntings logged, and Tomás confirmed the expected issue—the broken form's "Place order" button isn't keyboard-reachable. Running Rosa next, then the fixed bakery.
I'm checking both on the fixed site and found all 7 matches, with Gemma alone flagging 4 complaints and the guard flagging 1. Looking at that guarded false alarm, it seems Marisol found something real on the fixed site, so it should count as kept rather than planted or incomplete.
Tracing it down, I find the fixed thanks page has a "Back to the breads" link sitting as a plain inline anchor outside any card or header, giving it a small height under 24px that gets flagged as a small tap target. That raises the question of whether my check should account for the WCAG 2.5.8 spacing exception, where an undersized target still passes if a 24px circle around it doesn't overlap other targets — and I'm now working through whether the broken +/- buttons with their 8px gaps would actually pass …
Marisol's complaint checks out — the fixed site really does have a tiny 130x19px link, so that's genuine and not something I introduced. Tomás's tip also reveals a real bug: the guard is too strict when parsing evidence ids, since Gemma cited the full line instead of just the id, so I need to fix it to extract the E\d+ pattern from cited strings.
That parsing fix means scoring needs to change too — score.py should reconstruct the evidence log from raw complaints and re-run the judge, rather than relying on cached hauntings from run.json, so rescoring without the model stays meaningful. After the fix, Tomás's goal_not_completed complaint would correctly stay (it's honest — he genuinely didn't finish, not a site defect), while Rosa's fix confirms the guard correctly drops Gemma's …
I need to fix the id parsing so it extracts every E-number token from each cited string, keeping raw strings as "never measured" when no id is found. I'm writing a helper function that scans each cited item and pulls out matching identifiers into a list.
I also realize score.py should re-judge from raw complaints using evidence so re-scoring reflects the current guard, which means I need to check the EvidenceLog API in browser.py to reconstruct it properly.
I'll update the guard to extract E<number> ids from the cited text, and update eval/score.py to re-run the guard on saved raw complaints so guard changes can be re-scored without re-running the model.
I should commit the run-1 results now, before the guard fix, since that summary reflects the original scoring.
git add eval/results; git commit -q -m "Add first Gemma 4 E2B eval run (raw run.json per site plus summary)"; .\.venv\Scripts\python -m pytest -q tests/test_guard.py 2>&1 | Select-Object -Last 3
git add ghost_visitors/guard.py tests/test_guard.py; git commit -q -m "Guard: pull evidence ids out of quoted evidence lines"; git log --oneline -3
Next, I'm making score.py re-judge the saved raw complaints against the saved evidence with the current guard, instead of trusting the hauntings recorded at run time.
I need score.py to re-judge directly from the raw complaints, so I'm rebuilding the EvidenceLog by including every kind present and constructing items straight from the evidence dicts.
Updating the score test so it feeds real evidence ids and raw complaints with citations through the guard:
.\.venv\Scripts\python -m pytest -q tests/test_score.py 2>&1 | Select-Object -Last 3; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\score.py eval\results\gemma4-e2b\run-1 | Select-Object -First 10
The exit code -1 is just from truncating the pipe with Select-Object -First, that's fine. I'm reconsidering the "Complaints about the fixed site" row since it conflates goal_not_completed with real site problems — the former reflects a model navigation failure rather than an actual site defect. I'll keep the row as is but clarify the distinction in the README explanation.
I'll commit the scorer and the rescored summary as separate commits, since the rescored summary represents an actual result change: first the scorer fix, then "Re-score run 1 with the fixed guard."
The offline re-score confirms the guard now keeps 2 complaints on the fixed site, including Tomás's honest "I never finished" that the old parser had incorrectly discarded. I'll commit the scorer and the re-scored summary …
git add eval/score.py tests/test_score.py; git commit -q -m "Score: re-run the guard on saved raw complaints"; git add eval/results; git commit -q -m "Re-score run 1 with the evidence-id fix"; git log --oneline -2
Now I'll fix the "Back to the breads" link Marisol found. Then I'll look at why Tomás keeps getting lost on the order page in both runs.
Now I need to fix the demo app's thanks link in fixed mode and check Tomás's perception issue, so I'm looking at the thanks page and its describe() behavior in the browser.
I'm thinking about fixing the thanks link's tap target by giving it proper padding in fixed mode, while leaving the broken mode's small target as-is since that's already flagged as an issue there.
For Tomás's perception, I need him to announce the focused control like a screen reader would—reporting its role and accessible name, or nothing if the element lacks a ghost ID. I'm also puzzling over an odd focus jump in his fixed-mode run where typing into the select field seemed to skip ahead to the "Order" link instead of following the expected tab order, so I want to check the actual run data to understand what happened before implementing the announce feature and testing it on a fresh run.
The fixed-site test only walked / and /order, which is why I missed the thank-you page. I'm adding /thanks to the walk.
I see the test only walks / and /order, which explains why /thanks got missed — I need to add /thanks?name=... to the walk paths. With that fix the test should pass, and since the thanks link is padded consistently in both versions and the small-tap-target issue is already deduped by kind and page on /order, the broken expectations shouldn't change.
git stash push demo_site/app.py -q; .\.venv\Scripts\python -m pytest -q tests/test_browser.py -k fixed_bakery_measures_clean 2>&1 | Select-String "passed|failed|Back"; git stash pop -q; .\.venv\Scripts\python -m pytest -q tests/test_browser.py 2>&1 | Select-Object -Last 2
The new test fails on the old demo with exactly Marisol's finding, and passes after the fix. Committing:
git add demo_site/app.py tests/test_browser.py; git commit -q -m "Fixed bakery: give the thank-you page's back link a 24px+ tap target (found by Marisol)"; git log --oneline -1
Next, Tomás. In both runs he looped back to the header's "Order" link and reloaded the page. I'll check how keyboard typing and focus reporting work in act.
Now I'm looking at Tomás's run-1 steps to trace where the focus sequence went wrong — checking whether Tab after selecting the item actually moved to the name field, then email, rather than jumping straight to "Place order" as he described.
I notice that pressing Enter on the email field triggered a page reload at the /order URL, which suggests the focus was actually on the Order link, not the email field. I'm wondering if typing text into the bread select dropdown caused unexpected focus behavior, so I want to check how the "type" action is implemented for select elements.
I'm realizing the core problem: after navigation, focus resets to nothing, so the typing step actually went nowhere, and the model failed to track that. The real fix is to mimic screen reader behavior—announce which control currently has focus, and handle the case where typing happens with no element focused.
I'll implement this by checking document.activeElement before each keyboard action, matching it against the scanned controls to report either where typed text actually landed or that it went nowhere, and similarly reporting where focus moves after Tab/Enter presses. I'll write a helper that resolves the currently focused element into a readable description like "[3] combobox 'Bread'".
I found the issue: after each page reload, focus resets to the page itself, so Tomás's typing had …
Adding a test for what Tomás now hears:
I should add a test confirming that ghost typing with nothing focused doesn't deliver text anywhere, and that Tab still announces properly. The existing fixed-form submission test should still work since it reuses the prior scan, so I just need to add the new assertions alongside it.
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 15
I notice an unused variable assignment where the result is never used afterward, so I'll just call the method directly without storing it.
git add ghost_visitors/browser.py tests/test_browser.py; git commit -q -m "Keyboard ghost hears focus changes and typing that lands nowhere, like a screen reader"; git log --oneline -1
Next, run_eval.py gets a --label flag so a second run lands next to run 1 instead of overwriting it.
I need run_eval to support a --label flag so a second run saves to a separate folder rather than overwriting, so I'll give it a sensible default like "latest" and pass "run-2" explicitly. I should also check whether old background processes on the bakery and web UI ports are still running stale code, since the web UI loads visitor data at startup.
git add eval/run_eval.py; git commit -q -m "Eval: keep each run in its own labelled folder"; git push -q origin main 2>&1; git log --oneline -1; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --label run-2 --max-steps 16
Eval run 2 is running in the background and should take about 25 minutes. Meanwhile I'll update the README and fetch the challenge's submission template for the DEV post.
While the process runs, I should draft the README eval section and the DEV post, and check what's currently there. I also need to pull challenge details for the Week 4 template, so I'll look into using get_challenge_details via DevRelay and check its schema.
I need the challenge id first, so I'll call get_challenges to look that up.
The template link isn't in the full details I have. I should check the challenge page itself since dev.to posts often use a prefill URL for the template - let me search back through what I've already fetched for that.
The challenge page doesn't show the template text itself, so I'll copy the section structure from existing #hf26challenge submissions.
The status text is ambiguous since it shows both "Live" and "Ended," but given today's date it should still be live. For tags, DEV only allows four, so I'm narrowing down to devchallenge, hf26challenge, and then deciding between ai/opensource or a gemma-specific tag for the Best Use of Gemma category.
I should find a reference structure instead of guessing the template body—let me search DEV for existing hf26challenge submissions to see how others formatted their posts.
The template sections are: What I Built, Demo, Code, How I Built It, Why Does Open Innovation Matter?, plus a Prize Categories section for category entries. Checking on eval run 2:
I'm mapping out the template structure—intro, What I Built, Demo, Code, How I Built It, and the Open Innovation question, with Prize Categories as optional. Now I want to check on the eval progress.
I'm not sure the push succeeded, so I should check git status and the log line to confirm. While waiting, I'll draft the README eval section with placeholder numbers and start the post, after reading through the README fully.
The local commits are pushed. While run 2 works, I'm drafting the DEV post outside the repo and running a quick DEV search for related community posts.
I need to fix the README claim that complaints in fixed mode are always false alarms, since that's no longer accurate given recent findings — I'll update that after run 2. I also want to grab screenshots of the report and web UI for the README/post, planning to capture them via a quick headless Playwright script after that run.
I'll draft the DEV post outside the repo, in a separate local file, while waiting on run 2 numbers. Key facts to cover: the Gemma 4 E2B/Ollama CPU setup and timing, the run 1 results and bugs found, Tomás's limitations, the run 2 results, and honest caveats about the model's size and the heuristic nature of the …
That interlude works well. Now I should hold off on writing the post draft until I get the run 2 numbers from the eval, so I'll wait around ten minutes for those results.
While run 2 works, I'll write the post with placeholders for its numbers, saved outside the repo.
I'll draft the post now with placeholder XX values for run 2's numbers, since writing it is the bulk of the work anyway. I still need to grab screenshots of the eval report and web UI after run 2 finishes to include in the doc.
For the title, I'm leaning toward something punchy about "ghost visitors" — people who left the site without engaging, voiced by Gemma and proven real through code. I want to write it carefully, matching the user's plain-sentence style and avoiding overusing em dashes.
Tomás pressing Enter on the "Breads" link just reloads the home page and resets focus, since the href points back to "/". The model seems confused here, believing it tabbed to "Order" and pressed Enter there, when really focus was still on Breads after just one tab — a misread of where the cursor actually landed.
I think the real fix is a stronger prompt cue reminding the model to check "Focus is on" right before pressing Enter, since Enter activates exactly that control, not whatever the model assumes. Now I want to check how Marisol's run went, since I recall the "0 proven, 2 whispers" link issue got fixed there.
I found the bug: pressing Space on the select opens its dropdown in headless Chromium, and the subsequent Tab doesn't move focus because the popup seems to absorb the keystroke. In real Chrome, Tab would normally close the listbox and advance focus, so this looks like a headless-specific quirk where the open popup traps keyboard input.
The honest fix is to treat this as a harness limitation rather than a site bug: when the focused control is a combobox and the action is Space or Enter, instead of actually pressing it, report that it's a dropdown and the user should type an option's name to select it, mirroring rough screen-reader behavior. I also want to detect page reloads from Enter by setting a marker on the window before the keypress and checking afterward if it's gone, and I should …
Tomás's count stays at 0 since he ran out of steps rather than flagging goal_not_completed. For run 2, Gemma alone produced 5 false alarms while the guard had none. I'll commit these run-2 results, then fix the space-on-select and reload notice issues before committing again, possibly running a third pass focused on Tom.
Checking the time — it's Oct 28, deadline Oct 31 — there's room for a run 3 (~25 min). Tomás has never completed a run, so a run where he finally completes the fixed site while still failing the broken one would be the clearest validation of the core signal. That seems worth doing. Committing run-2 now.
Run 2 results are in: the broken site caught all 7 planted problems, and on the fixed site Gemma raised 5 false complaints, all correctly filtered out by the guard. …
git add eval/results/gemma4-e2b/run-2; git commit -q -m "Add Gemma 4 E2B eval run 2 (after the guard, demo and focus fixes)"; git log --oneline -1
I'm thinking about the keyboard branch logic for comboboxes—if a select is focused and the user presses space or enter, maybe I shouldn't actually trigger a press and instead return guidance suggesting they type an option name and tab away. I need to check whether Enter actually submits a form when focus is on a select element in Chrome, since that could change the right behavior here.
For detecting an unwanted reload, I'm planning to set a marker in the page before pressing a key, then after pressing, check if the URL is unchanged but the marker is gone, which would mean the page reloaded and focus landed back on the document itself. I should wait for the load state to settle before evaluating that marker, since checking too early while navigation is in-flight could throw an error.
.\.venv\Scripts\python -m pytest -q tests/test_browser.py 2>&1 | Select-Object -Last 15
The message is right; only my test's expected text was wrong (the dropdown reports its label, not its value).
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 3
Splitting into separate commits seems unnecessarily cumbersome given the non-interactive setup, so I'll just commit both keyboard feedback changes together as one atomic commit about hearing page reloads and typing into dropdowns instead of opening them. I also want to double check the prompt cue about adding guidance to KEYBOARD_ACTIONS noting that enter activates exactly the control described in "Focus is on."
git add ghost_visitors/browser.py tests/test_browser.py; git commit -q -m "Keyboard ghost: hear page reloads, and type into dropdowns instead of opening them"; git log --oneline -1
I'm also adding one prompt line for Tomás. Run 2 showed him pressing Enter on "Breads" while believing focus was on "Order".
I see the model pressed Enter while focus was actually on Breads, not Order—it assumed focus matched the control it intended to reach. I should add a clarifying note: Enter activates whatever "Focus is on" currently names, not the control being navigated toward. I'll commit this as a prompt tweak and then run trial 3.
.\.venv\Scripts\python -m pytest -q tests/test_agent.py 2>&1 | Select-Object -Last 2; git add ghost_visitors/agent.py; git commit -q -m "Remind the keyboard ghost that Enter acts on what has focus now"; git push -q origin main 2>&1; git log --oneline -1; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --label run-3 --max-steps 16
Run 3 has started. While it runs, I'm taking screenshots of a real report and the web UI for the README and the post.
While run 3 is in progress, I'll grab screenshots of the report and web UI — the UI on 8765 has stale code but it's probably fine to restart it for this. I need to locate the broken run-2 report directory and use a quick temporary Playwright script to screenshot the Rosa or Marisol card, clipping the view since a full-page shot could be too tall.
Clipping to roughly 1400px at 1200px width should work, saving as docs/report.png and docs/web-ui.png. Running Playwright alongside the eval should be safe aside from some CPU contention, and the web UI's Ollama ping shouldn't cause issues.
I'm noting that the broken run (20261028-005813, started around 00:58) involves a screenshot script that writes a temp file to %TEMP%.
The report screenshot works well. For the post I also want one that shows whispers being set aside, which is the honesty mechanic. Marisol's card from the run-2 fixed-site report has two.
The report image looks good overall. I'm noting a few small issues to flag: the clip might be cutting off thumbnails at the bottom so maybe adjusting the clip point would help, the horizontal scroll measurement on /thanks looks fine, and there's a slight misalignment in the "Ghosts" label in the web UI worth checking.
I'd also like to capture a screenshot showing the whisper-setting-aside mechanic — the fixed report has Marisol's card with two whispers, so I'll screenshot that card cropped to the whispers section to illustrate the honesty feature.
That screenshot tells the story on its own. On the fixed site, Gemma's Marisol says "this site is a nightmare", and the guard shows nothing measured backs it. Committing the screenshots:
That Gemma comment about the site being a nightmare makes a good hero image for the post, though I should guard against overstating it since nothing's proven yet. I'll commit the docs images now with a message like "Add report and web UI screenshots," then update the README with images and an eval section, waiting on run 3 to finalize the eval numbers.
git add docs/report.png docs/whispers.png docs/web-ui.png; git commit -q -m "Add screenshots of a haunting report, guarded whispers and the web UI"; git log --oneline -1
PS ~> ollama
Select model for Claude Code:
(no matches)
↑/↓ navigate • enter select • ← back
It looks like running ollama without arguments triggered an interactive launcher, and they navigated into a Claude Code integration menu showing "no matches" for selecting a model—likely because the filter requires models with tool-calling support that their current model doesn't have.
I should let them know they can back out or Ctrl+C to exit this menu, but make sure not to quit the Ollama app itself since the eval running in the background depends on its server. I should also check on the status of that eval since my shell session got interrupted by this message.
You don't need that menu for Ghost Visitors, so press Esc or Ctrl+C to leave it. Running ollama with no arguments opens Ollama's built-in launcher, which sets up other coding tools like Claude Code to use a local model.
"(no matches)" means the launcher didn't find any installed model it would offer for Claude Code. My guess is it doesn't list gemma4:e2b there because that model is small, but I haven't confirmed why. Either way it changes nothing for this project: Ghost Visitors talks to Ollama's local server directly at http://localhost:11434/v1.
Please don't quit Ollama from the system tray or close its window yet. Eval run 3 is using it right now. So far, on the broken site, Marisol has finished the order with all 3 of her problems proven. Tomás ran out of steps, but with both …
"Browserbase is a possible partner category ($100)" is it the only one and why arent you using it?
No, Browserbase isn't the only partner category that could fit. I left it out because the main pitch is "the site you test never leaves your laptop", and Browserbase runs the browser in their cloud. Every page, screenshot and form entry would go through their servers. That's fine for a public site, but it contradicts the "test your staging site privately" story the post leads with.
It could still be added honestly as an option rather than the default. Something like --browser browserbase for public sites, while Playwright keeps running locally by default. Playwright can connect to a Browserbase session directly, so the agent loop and guard wouldn't change. Two things I'm not sure of without trying:
- whether Browserbase sessions allow per-ghost phone sizes, touch and network …
Only one of them genuinely suits the app: GitHub Actions with a ghost haunting the demo bakery on every push, with the tests in the same workflow. I'd skip the rest.
Why that one fits:
- It's how a tool like this would really be used: before you merge, a ghost walks your site and tells you if you just broke something for Marisol, Tomás or Rosa.
- It makes the "why open" argument stronger instead of diluting it. Gemma downloads onto the runner, so a CI haunting needs no API key, no secret and no bill. A closed model would need a paid key stored in the repo's settings.
- It gives the post a concrete result: a pull request that breaks the bakery gets haunted, and one that fixes it rests in peace. It also honestly continues Ghost Customers, which the post already credits.
- Cost: …
Nothing new to do here. Those two notices are for eval runs 1 and 2, which finished earlier, and both sets of results are already committed. Run 3 is still running and should finish in about 10 minutes.
Once it's done, I'll commit its results and update the README's eval section. The CI ghost waits for your "go".
Those two are already handled with results committed, so just a brief note needed. Run 3 is still in progress, waiting for the user to say go.
go
Building the CI ghost now. First I'll check how the CLI's haunt command ends, so I can add an exit code CI can act on.
I'm drafting the GitHub Actions workflow with two jobs: a tests job running pytest with Playwright on Ubuntu/Python 3.11, and a haunt job that installs Ollama, caches the model directory, pulls the gemma model, and starts the bakery service.
Now I'm thinking through how to demo "PR that breaks the bakery gets haunted" with a matrix over broken/fixed modes, weighing timing costs since Rosa's run took 173-250s locally. I want CI to actually fail when hauntings are found, so I'm considering adding a --fail-on-haunting CLI flag, but need to handle the broken-mode leg carefully so it doesn't just turn every CI run red by default.
I'm landing on a cleaner design: the …
I'm adding a --fail-on-haunting flag to exit with code 3 when a haunting is proven, plus a --summary PATH option to append a Markdown summary suitable for GitHub step summaries, which means adding a summary_markdown function to report.py. I need to check the run dict structure in run.json and how report.py's render function is signed to make sure I'm pulling the right fields like visit names, outcomes, verdicts, and haunting details.
Adding a Markdown summary to report.py, used for GitHub's job summary:
I'm planning the markdown format for each haunting entry — a heading with the name and verdict, a quoted summary line, and bulleted details with counted whispers.
Adding tests for the exit code and the summary. The test swaps in a fake run, so no model or browser is needed:
Testing the CLI fully would require a fake LLM server and real browser, which is heavy, so instead I'm thinking about monkeypatching ghost_visitors.run.run_haunting and check_model to test the CLI exit code more simply, since the function imports run_haunting locally and patching the module attribute should still work.
.\.venv\Scripts\python -m pytest -q tests/test_cli.py tests/test_run.py 2>&1 | Select-Object -Last 8
I'll bundle the report.markdown changes with the CLI flags into one atomic commit for "--fail-on-haunting and --summary for CI." For the workflow, since the eval process already imported its modules at start, changes on disk won't affect the currently running instance, so that's fine.
git add ghost_visitors/report.py ghost_visitors/cli.py tests/test_cli.py; git commit -q -m "CLI: --fail-on-haunting and a Markdown --summary for CI"; git log --oneline -1
I'm drafting the workflow file, planning to use Python 3.12 with Playwright 1.49.1 installed with dependencies for Chromium on Ubuntu 24.04, and setting up Ollama via its install script so it runs as a systemd service before pulling the needed model.
For the haunt job, I'm building a matrix strategy testing both broken and fixed modes with expected visitor counts, checking out the repo, installing dependencies, waiting for Ollama's API to come online, pulling the Gemma model, then launching the bakery server on port 8001 and polling until it responds before sending in the test visitor.
I'm second-guessing whether "fixed" should expect zero hauntings—if Rosa runs out of steps without completing her goal, that triggers a goal_not_completed haunting even on a clean site, which could cause …
git add .github/workflows/haunt.yml; git commit -q -m "CI: run the tests, then send Abuela Rosa to both bakeries with Gemma on the runner"; git push -q origin main 2>&1; git log --oneline -1; gh run list -R emarc99/ghost-visitors -L 2 2>&1
gh --version 2>&1 | Select-Object -First 1; gh run list -R emarc99/ghost-visitors -L 3 2>&1
The workflow is queued on GitHub. Checking eval run 3:
The workflow is queued now, so I'll check on run 3's status while that's processing.
Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416765.txt -Encoding utf8 | Select-String -NotMatch '^127\.0|^/thanks|HTTP/1' | Select-String -Pattern "Tomás drifts" -Context 0,40 | Select-Object -Last 1; gh run list -R emarc99/ghost-visitors -L 1 2>&1
An ImgBot PR showed up to optimize PNGs since the repo has it installed — good real-world test of the PR trigger, though I shouldn't merge it without checking with the user. I also want to look back at Tomás's run 3, which ran out of steps again, to see what's driving that goal_not_completed complaint.
Tomás made a huge improvement, navigating cleanly through the whole form with just ~4 more steps needed—he ran out of steps right at the end with the 16-step limit. I'm also noting he correctly self-reports, and that pressing Enter in a text field implicitly submits the form, which matters for counting his remaining tab steps to completion.
Keyboard navigation inherently takes more steps than mouse clicks, so a fair design change might be scaling the step budget for keyboard-only ghosts—something like doubling max_steps for them, since each Tab press counts as a step. This would need another eval run, but I could limit it to just Tomás on both sites rather than a full run with all ghosts to save time. I'm considering implementing this as a simple conditional in the haunt logic: double …
git add eval/results/gemma4-e2b/run-3; git commit -q -m "Add Gemma 4 E2B eval run 3 (keyboard ghost hears reloads and dropdowns)"; git log --oneline -1; gh run list -R emarc99/ghost-visitors -L 3 2>&1
Run 3 finished around 1530 seconds, matching the expected pattern. Now I need to implement a keyboard step budget and check how the haunt mechanism is handled in agent.py.
Every Tab costs Tomás a step, while Marisol fills a whole field in one. I'll give keyboard ghosts twice the step budget.
I'm doubling the step budget for keyboard-only ghosts by multiplying max_steps by a factor of 2, then using that budget wherever step limits are checked. I should verify existing tests like the Tomás scripted test still pass, and consider adding a test confirming a keyboard-only ghost exhausts its budget at double the normal step count.
.\.venv\Scripts\python -m pytest -q tests/test_agent.py 2>&1 | Select-Object -Last 4
I'll commit, then add --ghosts to run_eval and do a Tomás-only run at a higher max-steps limit, which should take roughly 20 minutes total. With only Tomás in scope, the total planted count is 2.
git add ghost_visitors/agent.py tests/test_agent.py; git commit -q -m "Give keyboard ghosts twice the steps, since every Tab is a step"; git add eval/run_eval.py; git commit -q -m "Eval: --ghosts to re-run a single ghost"; git log --oneline -2; $env:PYTHONIOENCODING='utf-8'; .\.venv\Scripts\python eval\run_eval.py --label run-4-tomas --ghosts tomas --max-steps 16
Tomás-only run 4 has started with his doubled 32-step budget. Checking the GitHub Actions run meanwhile:
gh run view 40610622220 -R emarc99/ghost-visitors 2>&1
Tests pass on GitHub (1 m 13 s), and both haunting jobs are running. I'll check back once they and Tomás's run have had time.
Tests pass on CI. I'm thinking about the Node 20 deprecation warnings—checking that checkout@v5, setup-python@v6, and upload-artifact@v5 all exist and use Node 24 by this point, which they do, so I'll plan to bump these versions in a single commit later, after the current jobs finish since pushing won't cancel in-progress runs.
While waiting on those runs, I'll draft the README's eval section, compiling results across runs 1-3 into a table of planted issues found, false alarms, and fixed-site complaints kept, with run 4 still pending. I'm also noting the ImgBot PR that showed up is harmless and worth leaving for the user to review rather than merging myself, and …
gh run view 40610622220 -R emarc99/ghost-visitors 2>&1 | Select-String "Haunt the|Tests in"; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416766.txt -Encoding utf8 | Select-String -NotMatch '^127\.0|^/thanks|HTTP/1' | Select-Object -Last 6
The fix for the haunting test passed in CI, taking about six and a half minutes. Now I'll wait for the broken one to finish and check the annotations afterward, which should take another four minutes or so.
The fixed-bakery job passed in 6m36s with no issues found, while the broken-bakery job is still running and Tomás is progressing further than before on the fixed site. I'll keep monitoring the broken-bakery job.
gh run view 40610622220 -R emarc99/ghost-visitors 2>&1 | Select-String "Haunt the|X |conclusion"; Get-Content ~\.cursor\projects\c-Users-me-Documents-devposts-life-after-code\terminals\416766.txt -Encoding utf8 | Select-String -NotMatch '^127\.0|^/thanks|HTTP/1' | Select-Object -Last 8
CI is fully green now, with the broken bakery test exiting as expected in just over 9 minutes. I'll just link to the job summary rather than trying to fetch it via the API. For the fixed test, Tomás seems to wander through some steps before going off track, so I'll wait for it to finish and check the result, while also updating the Actions versions for Node 24 in the meantime.
Based on the annotations, only checkout and setup-python are flagged as Node 20, while upload-artifact already supports Node 24, so I'll bump checkout to v5 and setup-python to v6, leaving upload-artifact at v4.
The workflow passed as expected—the broken bakery correctly failed with proven hauntings, and the fixed one ran clean. I'll now bump checkout@v4 and setup-python@v5 since GitHub flagged them for …
(Get-Content .github\workflows\haunt.yml -Raw) -replace 'actions/checkout@v4','actions/checkout@v5' -replace 'actions/setup-python@v5','actions/setup-python@v6' | Set-Content .github\workflows\haunt.yml -NoNewline; git diff --stat; git add .github/workflows/haunt.yml; git commit -q -m "CI: move checkout and setup-python to their Node 24 releases"; git log --oneline -1
I'll hold off on the README updates until run 4 finishes, then write them with the actual results in hand.
This confirms the core contrast: on the broken site, Tomás tabbed past "One more" and focus fell off the page, leaving Place order unreachable, while on the fixed version he completed the order in 13 steps. Now I need to commit run 4, push, then handle the README eval and CI section, update the post draft, and create the DEV draft.
The test confirms the fix works: Tomás completes the order in 13 keystrokes on the fixed bakery, while the broken site traps him in an endless 32-step loop due to the unfocusable "Place order" button. I'll commit this run 4 result and push it now.
git add eval/results/gemma4-e2b/run-4-tomas; git commit -q -m "Add Tomás-only eval run 4 with the keyboard step budget"; git push -q origin main 2>&1; git log --oneline -1
Now the README: screenshots, the eval results across all runs, and a "Use it in CI" section.
The run-1 summary looks like the rescored version with false alarms at 3/1, which is good. I'll just do one combined README commit covering screenshots, CI usage, and eval results rather than splitting into separate commits since partial staging would be awkward here.
git add README.md; git commit -q -m "README: screenshots, CI usage and results from four eval runs"; git push -q origin main 2>&1; git log --oneline -1; gh run list -R emarc99/ghost-visitors -L 4 2>&1
I'm noticing concurrent pushes waste time running the full job twice, so I should add a concurrency group with cancel-in-progress to avoid redundant runs. I'm also considering a paths-ignore rule so docs-only commits like README or eval results changes skip triggering the workflow entirely.
I'll add a concurrency group to cancel superseded runs, and configure push triggers to skip docs and eval-result-only changes.
git add .github/workflows/haunt.yml; git commit -q -m "CI: cancel superseded runs and skip docs-only pushes"; git push -q origin main 2>&1; git log --oneline -1
Next, I'm filling in the post draft with real material: the screenshots, Tomás's actual run-4 lines, all four runs, and the CI story. First, his exact words from run 4:
I'm filling in the post draft's placeholders—demo images, the snippet, the run2 table and notes—plus adding a CI section and Copilot prize category text, using raw.githubusercontent URLs for the images and noting that an ImgBot PR could update them without changing the paths.
I should restructure the eval section into a runs 1→4 narrative and rewrite the whole post for coherence, include both Gemma and GitHub Copilot as prize categories since Actions automation is explicitly mentioned in the category description, add an AI assistant disclosure to credits, and pull the real fixed Tomás snippet with run-4 steps 1-12 and …
Now the full post, rewritten around the real runs:
I've got good material here, especially the broken step 10 with the unnamed textbox and steps 21-22. I can quote these directly. Now I'll write up the full post, keeping it engaging but concise, and then draft it as a DEV article via DevRelay with published set to false, checking the tool schema first.
Checking the claims in the post. First, the test count:
I should double-check my numbers: the 46 test count needs verification against the actual suite run, and the "21 ghost visits" figure is actually wrong — it should be 20 based on 6 per run across three runs plus 2 in the fourth.
The "three to twelve minutes" range checks out against Rosa and Tomás's timings, and the false alarm count needs adjusting to "per full run" since the fourth run had zero. The claim about the guard letting through exactly one unplanted complaint also holds since that case was correctly excluded from false alarms — overall the guard preserved every real finding while filtering the rest. I still need to actually run the suite to nail down the final test count.
.\.venv\Scripts\python -m pytest -q 2>&1 | Select-Object -Last 2
The claims check out, and the two small number fixes are applied. Saving the post as an unpublished DEV draft with the challenge tags:
I'm settling on a title and description emphasizing offline persona ghosts with browser-verified complaints, tagged with devchallenge, hf26challenge, gemma, and a11y. Now I need to pull the body content from the file to use as the submission text.
I need to include the full body directly in the tool call arguments since there's no file-reference option, so I'll paste it in carefully to match the original text exactly.
The draft is saved. One last check on the latest GitHub runs and the ImgBot pull request:
Before summarizing, I should check the latest CI run status and look at the open ImgBot PR using gh.
gh run list -R emarc99/ghost-visitors -L 4 2>&1; gh pr list -R emarc99/ghost-visitors 2>&1
Those earlier runs predate the concurrency config, so they're redundant and will each run about 10 minutes — harmless since it's a public repo with free minutes, but I might as well cancel the two older ones to save resources.
gh run cancel 40612993252 -R emarc99/ghost-visitors 2>&1; gh run cancel 40613026871 -R emarc99/ghost-visitors 2>&1
The CI ghost is built and passing on GitHub. The challenge post is saved as an unpublished DEV draft, now entered for both Best Use of Gemma and Best Use of GitHub Copilot. Everything is committed in small commits and pushed to ghost-visitors.
The CI ghost. The workflow runs the 46 tests, installs Ollama on GitHub's machine, pulls Gemma, and sends Abuela Rosa to both bakeries. The broken bakery must be haunted and the fixed one must pass. Her report appears in the job summary, and no API key or secret is needed. The first run passed all three jobs, with the two hauntings taking 9 and 7 minutes. I also made it skip pushes that only change docs, and cancel a run when a …
There's nothing new to act on. Those two notices are for eval runs 3 and 4, which finished earlier. Both are committed, pushed, and already reflected in the README and the DEV draft.
The open decisions are still yours: whether to publish the draft, whether to merge the ImgBot pull request, and whether to embed the agent session in the post.
yes, use devrelay
Honest limits
- The checks are inspired by WCAG (24px targets, 4.5:1 contrast), but they aren't a full audit. Rosa's 200% zoom is a smaller viewport with a 2× device scale factor, which is close but not identical.
- E2B is small. A larger Gemma should make Tomás steadier, and switching is one flag:
--model gemma4:e4b. - On a CPU, a full haunting takes minutes, not seconds. Good for a coffee break or a CI job, not a keystroke.
Why Does Open Innovation Matter?
The site you test never leaves your laptop. Ghost Visitors is most useful before launch: on a staging site, an intranet, or a client's half-finished shop. Those are exactly the pages you shouldn't stream to a third-party API. With Gemma on Ollama, screenshots, form contents and page text stay on your machine. Every visit in this post ran with no cloud model involved.
A run costs nothing, so you can run it again. Four eval runs, 20 ghost visits, a few hundred model calls, and I never thought about a bill. That's what made it practical to read a failed run, fix one thing, and run it again. It's how Marisol's bug and Tomás's deafness were found.
CI without secrets. A closed model in CI means storing a paid key in the repo's settings and paying for every pull request, including ones from strangers. An open model is a 4 GB download on the runner. Anyone who forks the repo gets a working haunting check for free.
Open weights made "don't trust the model" practical. The model is a swappable part behind an OpenAI-compatible URL, so I could build the whole system around checking it. The guard works the same with a 2B model on a CPU or a hosted Gemma, and the eval shows exactly what the guard adds for each.
It won't disappear. The model is a file on my disk and the code is MIT. If a service shuts down next year, the ghosts keep haunting.
Prize Categories
Best Use of Gemma. Gemma 4 E2B is the mind of every ghost. It decides each step from the ghost's perception, keeps the persona's voice, and writes the verdict, epitaph and complaints. It runs locally through Ollama and on a GitHub runner's CPU, and the same code works with hosted Gemma through any OpenAI-compatible endpoint. The eval compares Gemma's raw output with the guarded output, so you can see where a small open model is reliable and where code has to check it.
Best Use of GitHub Copilot (GitHub Actions). Actions turns a haunting into a check that can fail. haunt.yml runs the 46 tests, then a two-job matrix installs Ollama on the runner, pulls Gemma 4 E2B, and sends Abuela Rosa to the broken and the fixed bakery. Each job asserts the exit code it expects: 3 (proven hauntings) on the broken site and 0 on the fixed one, so the workflow checks that the ghost can tell the two sites apart, not just that it runs. Rosa's report goes into the job summary with the measurement behind each haunting, and her screenshots are uploaded as an artifact. The first run passed with no secrets configured: tests in 1m13s, the broken bakery in 9m4s, the fixed one in 6m36s. Runs replaced by a newer push are cancelled, and pushes that only touch docs are skipped, so CPU minutes aren't wasted on a model that runs on every push.
Credits
The idea grew out of Ghost Customers, my GitLab Transcend hackathon project, where scripted personas walk every build in CI. Ghost Visitors is a new project, built this week from an empty repo. Here an open-weight model drives the browser, it can be pointed at any site, and its claims are checked against measurements.
I built Ghost Visitors with an AI coding assistant (Cursor) helping me write and test the code and draft this post. Every run, number and quote above comes from the committed eval files.



Top comments (0)