fomo-qa
This makes "prepare a clean, consistent fomo-kernel dogfood environment" a procedure. It is a QA tool for development and maintenance, answering "I changed the engine — walking through as a real user would, is the experience right?", and it guarantees every run tests the same thing: the latest origin/main.
This is the mandatory standard path for every fomo-kernel dogfood (v1, fixed 2026-07-20) — every dogfood starts here. Do not improvise an environment on the spot.
Cross-client contract source (since 2026-07-21): docs/qa-runbook.md in the kol_collector/fomo-kernel repository (PR #275) defines what counts as a compliant QA run — seven gates (version gate / isolated root / receipt throughout / verdict+verify / archived manifest / privacy lint / findings disposition). A session missing any of them is not a QA run and its conclusions may not be cited. The seventh was added on 2026-07-27 (#417): a session passing the first six while leaving behind no replayable asset is exactly what this loop actually looked like for a year. That docs/qa-runbook.md, together with the repo-root AGENTS.md, must stay independent of this skill and of any personal registry — fomo-kernel is a public product a stranger clones on any machine, and cannot assume they have this skill. When the two disagree, the runbook wins.
What this skill is: the runbook's seven gates automated into a repeatable procedure plus the qa_env.sh tool, for ting's own maintenance of fomo-kernel. Since 2026-07-21 it is reachable through ai-harness's discovery registry, so Claude, Codex and Antigravity all call the same canonical content under the same name fomo-qa (see ai-harness/inventory/fomo-qa.json). It is not for anyone else — an external user has no such skill and needs none; they follow the public docs/qa-runbook.md above.
Why it exists: a 2026-07-19 audit found 17 of 18 worktrees behind main (the worst by 28 commits). Dogfood had been running on development worktrees each pinned to its own base — testing some past slice of the product, with no way to learn afterwards which slice. "It runs" is not "it tested the latest version". This skill blocks that mechanically (issue #250).
Its place in the eval system: this is also the execution procedure that
docs/eval-design.md's evidence level 4 (human review) and observation surface 1 (content-free interaction receipts) had always lacked. A walkthrough ends by producing aux_receiptowner verdict, turning the layer eval currently marks "pending owner dogfood" into a machine-readable annotation that accumulates. Since 2026-07-27 it produces one more thing: step 6 converts every miss into a replayable episode underevals/episodes/, so a dogfood produces a permanent regression asset rather than a one-off observation — that is what "what we get is stable enough to keep" means.
When to use it
- The user says
/fomo-qa, "dogfood fomo-kernel", "跑一次 fomo QA", "走一次復盤驗收", "準備乾淨的測試環境" - After changing the engine,
SKILL.md, or card rendering, to judge the experience from the user's side - To confirm a change really runs on the latest version and that the card really comes out
Not for reviewing a real user's trades — that is the product skill fomo-kernel. This skill only makes the acceptance environment clean and consistent; the walkthrough itself still follows the product's SKILL.md.
Coverage (v1, fixed — do not claim beyond it)
This round verifies L1: environment consistency + engine CLI contract + agent walkthrough behavior. Completing /fomo-qa means "the engine and agent behavior were verified on the latest version". It does not mean "the user's experience was verified on every client". The following are known follow-ups, out of this procedure; do not claim the experience is green after a run (that is precisely the false-pass trap in #230):
- L2 card visuals (next round): the card HTML is never actually rendered in a browser and compared by screenshot (today even
test_card_html.pymakes string assertions and takes zero screenshots). Visual consistency, layout and dark mode can only be eyeballed. - L3 interaction delivery (partly an inherent ceiling): "the option buttons really appeared and the user could really click them" is a client-layer fact (Claude native options vs typing by hand in Codex) and cannot be verified headlessly. Here we verify only the contract layer — the card text was pasted into the conversation, the questions were presented — which
tools/ux_receipt.pycan carry. The native-interaction half on Codex is #230's ceiling and rests on the owner's manual verdict. - A general HTML interaction document + the fastest plain-text completion experience: next round.
Hard isolation guardrails (read first, non-negotiable)
Never touch the real records:
~/Side_project/investment_note/holds ting's real investment records. Only the "real trades" data source reads one CSV there, read-only; nothing else is read or written.Coach state is isolated to a dogfood-only root, and the real one is not reachable: dogfood always uses a separate
~/.trade-coach-dogfood, fully apart from the~/.trade-coachyou use with the real product. Clear it withqa_env.sh reset(which backs up first and fail-closed refuses to touch the real root or investment_note). The real~/.trade-coachis managed only byreset-fomo-coach.sh; the dogfood procedure never touches it. Never hand-write anrmagainst any coach root.TRADE_COACH_HOMEroutes writers only — it never stopped anything reading the real root, and in #557 an agent that judged the isolated root unfamiliar and empty read the real ledger on its own initiative. Step 0'sisolatecloses that path by replacingHOME, and is a gate rather than a request. A dogfood root that looks empty is a fresh dogfood root; never go looking for a fuller one, and never compose an absolute path to the real one — that is the one hole this lane cannot close.Work only in the dogfood worktree: every engine command runs inside the detached worktree created by
qa_env.sh up.qa_env.shis itself fail-closed and only operates on a worktree whose path containsdogfood, so a slip cannot discard another session's uncommitted work.Do not change product code: QA reads, it does not edit. If the walkthrough finds a bug, write it down and open an issue; do not fix it in the dogfood worktree (it is detached and exists to be tested, not developed).
Public text passes the privacy lint first (bought by the #274 incident): the repository is public, and real tickers, specific amounts, or
TICKER#date#seqposition ids must never appear in an issue, PR, comment or commit message — text channels count, not just files. If this QA session used real trade data, run every draft destined for GitHub through the lint first (fromskills/fomo-kernel/inside the dogfood worktree):python3 tools/privacy_lint.py --against ~/Side_project/investment_note/trades/fomo/trades.csv /tmp/draft.mdOnly exit 0 may be posted. On a hit, rewrite as a de-identified description ("N individual stocks", "concentration is high") and re-scan until clean. The output is masked, so the lint result itself is safe to show. Real values stay local (memory, local notes).
qa_env.sh lives in this skill's directory. Every qa_env.sh path below is written for Claude (~/.claude/skills/fomo-qa/qa_env.sh); on Codex use ~/.agents/skills/fomo-qa/qa_env.sh, and on Antigravity ~/.gemini/config/skills/fomo-qa/qa_env.sh. All three are symlinks to the same canonical qa_env.sh with identical content — pure bash, independent of the client calling it.
Cross-client execution gaps (being in the discovery registry only guarantees the skill is found, not that every step runs correctly; see the ai-harness task record):
qa_env.sh,tools/ux_receipt.py,docs/qa-runbook.md's seven gates, and the Step 0–6 skeleton run as-is on all three clients, with no rewriting.- If Step 4 presents questions through Claude's native option tool (for example
AskUserQuestion), that is a Claude-only capability. Codex and Antigravity have no equivalent and must degrade to fixed-format plain-text options, recorded inux_receipt.pyasplain_textmode, notnative_options. - The rendering-pipeline test mentioned in walkthrough rule 1's "try the widget once" (for example some Artifact-style publishing tool on Claude) is a Claude-only MCP tool. Codex and Antigravity must test with whatever equivalent rendering they have, or — having none — record
widget_attempt_failedand degrade to Markdown. Do not copy the Claude-side tool name. qa_env.sh's assumptions about the current working directory and worktree have not been tested against Codex's or Antigravity's own working-directory models. On a first run there, usestatus(read-only) to confirm the behavior matches expectations before going further.
The fixed procedure
Step 0 — Isolate this shell, then the version gate (read-only)
qa_env.sh refuses every command until the shell it runs in has the account's own coach root out of reach (#557), so this line comes before everything else — including status — and has to be repeated in every later shell of the campaign:
eval "$(~/.claude/skills/fomo-qa/qa_env.sh isolate)"
It exports the dogfood TRADE_COACH_HOME and replaces HOME with a throwaway directory, so that ~/.trade-coach — the path review.py, coach.py, tools/ux_receipt.py and any improvised shell command all compose by default — names nothing. Every value in the block is resolved against the account's real home before the override, which is why the repo, the dogfood worktree, the receipt archive, git's configuration and Python's user-installed packages keep working after it. Export any FOMO_DOGFOOD_* overrides for a concurrent session before this line, while $HOME is still the account's own.
It is a bounded guarantee, and reporting it as more than that is the failure it was written against: an absolute path typed on purpose still reaches the real root, and only running the campaign in a container would change that. Do not type one.
~/.claude/skills/fomo-qa/qa_env.sh status
At a glance: the latest origin/main sha, how far behind the dogfood worktree is, and whether the dogfood coach state (the isolated root, not the real ~/.trade-coach) is a clean new user. If it is behind, do not go on — update via Step 1 first. Report main@<sha> to the user explicitly: that is the version this QA tested.
status also reports one extra line, this skill's own freshness — checking the checkout actually running qa_env.sh (wherever the symlink points, which is not necessarily the dogfood worktree) against origin/main. It is report-only: it never fetches and never blocks status, and when behind it prints a copy-pasteable fix.
Step 1 — Clean worktree (pinned to the latest main)
~/.claude/skills/fomo-qa/qa_env.sh up
Creates (or refreshes) ~/Side_project/kol_collector/fomo-kernel-dogfood at --detach origin/main. This worktree is dedicated to QA and is never used for development. The working directory for every later command:
cd ~/Side_project/kol_collector/fomo-kernel-dogfood/skills/fomo-kernel
Step 2 — Open the campaign (once per conversation, not once per route)
Steps 0–3 are the campaign setup: one worktree, one isolated root, one client/model/effort identity, one acceptance campaign. They happen once. Everything after them is a route run — one first_review, one refresh, one weekly_review — and a conversation may contain several, each with its own receipt and its own archived run_id.
Three identities that are deliberately not one-to-one (#544):
| What it is | How many | |
|---|---|---|
| Campaign | this conversation, this worktree, this isolated root | one |
| Route run | one product command lifecycle: prepare → preview → finalize, or a refresh |
as many as the session walks |
| Receipt | one route-specific, append-only evidence trace | exactly one per route run |
Never merge route runs into one receipt. A first_review owes two cards and a cash anchor, a refresh owes a card-free change surface and no cards at all — one mixed trace could satisfy neither verifier. The reusable unit is the campaign, not the receipt.
Step 0's isolate already routed the whole toolchain into the dogfood-only coach root — review.py, coach.py and tools/ux_receipt.py all three honor TRADE_COACH_HOME (ux_receipt since the #269 fix, merged in PR #275), so that one export keeps prepare/preview/finalize/data-status and the receipt consistent throughout. If this is a new shell, re-run it before anything else:
eval "$(~/.claude/skills/fomo-qa/qa_env.sh isolate)"
Simulate a brand-new user (the default; runs first-review):
~/.claude/skills/fomo-qa/qa_env.sh reset # back up, then clear the dogfood root to a fresh new userSimulate a returning user (runs weekly-review / due-revisit): do not reset. Keep the dogfood coach state left by a previous campaign and go straight to Step 3. Lesson from 2026-07-20: a freshly reset session can never test memory continuity or problem-ledger continuity (memory is
not_applicable, and an empty prior problem ledger will not "catch" you). Verifying "did last time's problem follow up?" requires a book that already has a finalized review behind it — do not mistake its absence for a fix working.
Confirm with the user which one to simulate; default to "brand-new user" when unsure. This is a campaign-level choice, made once. It is not re-asked before each route run: a campaign that opened fresh and then finalized a first_review is a returning user for everything that follows, without a reset and without leaving the conversation. That is the cheapest way to reach the returning-user routes, and since #544 it is the documented one.
This isolation must survive into every later shell — if commands each start a new shell, re-run the isolate line in every one, for every route run in the campaign. qa_env.sh refuses when it has not been; the engine and receipt commands do not, because they are product commands a real user runs against their own root, so a shell that quietly lost it writes the dogfood run into the real book. Establish it first, every time.
Step 3 — Choose a data source (one of three, standardized)
| Data source | Path used in commands | Fits |
|---|---|---|
| Real trades (read-only) | ~/Side_project/investment_note/trades/fomo/trades.csv |
True acceptance: it asks about ting's own motives, which exposes the most |
| Mock persona | mock/<persona>.csv inside the worktree (see mock/SAMPLES.md, e.g. sample_ai_holder, sample_tw_mixed) |
Fast, zero privacy risk, reproducible |
| Test-drive | --test-drive (no CSV) |
Demonstration only; persist:false, zero writes, isolated root |
The real trade file currently holds roughly 1,125 rows across 76 symbols, mixing Taiwanese and US markets and mixed date formats — good stress-test material.
Step 4 — Walk through (follow the product's fixed lifecycle; do not rewrite it)
After cd .../fomo-kernel-dogfood/skills/fomo-kernel (and confirming Step 2's export TRADE_COACH_HOME is live in this shell, so prepare/preview/finalize all land in the isolated dogfood root), follow the product SKILL.md's fixed lifecycle. Summary — for detail and edge cases the product's SKILL.md / flows/* / references/* are always authoritative:
# 1. prepare —— read review_plan.flow_path to decide which flow to follow
python3 engine/review.py prepare <CSV or --test-drive> --language zh-TW
# for test-drive, note review_plan.state_root; every later preview/finalize/resume needs --root <state_root>
# 2. agent work —— declare host capability, make the qualitative judgments, ask every required
# question in question_queue, build an inferred thesis for uncovered positions, and write the
# "no numbers" narrative (answers.json / narrative.json must pass their schemas)
# 3. preview —— validate, then render the private / public previews
python3 engine/review.py preview --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
# 4. embed the full card preview → ask the user to pick a rule / write their own / skip
# 5. finalize —— atomically commit the canonical bundle
python3 engine/review.py finalize --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
The UX receipt runs through the whole walkthrough (mandatory — this is what connects QA to eval): a walkthrough is not "the engine finished". Every user-visible step goes into the receipt (the product's tools/ux_receipt.py). That content-free receipt — only session id, capabilities, pass/fail, no trade content — is the machine-readable annotation feeding eval's evidence level 4.
Below is a complete, directly copyable first_review trace. The order is a contract, not a suggestion: verify hard-checks which events must appear, exactly how many times, and which precedes which. Get it wrong and the whole session is void with no way to repair it (the trace is append-only). Record each line immediately after the user actually sees that thing; never save it all for the end.
# qa-trace: first_review
# 0) Declare host capability right after prepare. --adapter must match the capability
# set you declare (plain_text / native_options / validated_widget); start adds the
# universal plain_text and markdown_inline fallbacks itself — do not pass them again.
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route first_review \
--adapter validated_widget --question-mode native_options --card-mode widget
# 1) Cash anchor (#357): exactly once on first_review / weekly_review, and where it goes
# follows the outcome. `found_in_source` is read out of the statement during prepare, so
# it must come before the first question and the first card — recording it later is
# judged out of order. When the source carries no balance the plan says so
# (`input.cash_anchor.status: "absent"`) and the question is asked at the card beat
# instead; the weekly trace below shows that shape. There is no outcome for "did not
# ask": a run where the user was never offered it records nothing, and `verify` fails.
python3 tools/ux_receipt.py event --session-id <ID> --event cash_anchor_checked \
--cash-outcome found_in_source
# 2) One row per question asked. Question text never enters the trace: a question from a
# validated dynamic surface records "source + sha256 of the presented text" instead, and
# the two must appear together (this replaces the removed --question-id).
python3 tools/ux_receipt.py event --session-id <ID> --event question_presented \
--mode native_options --surface-source validated_dynamic --surface-digest <64-hex-digest>
# 3) Record this the instant the user answers the last required question, before running
# preview — it is #236's measurement start for "answered → card".
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received
# 4) Cards are always "artifact first, presented second", and both rows need --stage
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
--stage preview --mode widget
# 5) Record when the "pick a rule / write your own / skip" choice is shown.
# --grounding-check-file is required (#293)
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
--mode native_options --grounding-check-file <grounding-check.json>
# 6) The final card after finalize — again artifact first, then presented
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
--stage final --mode widget
That leading
# qa-trace: <route>line is not decoration.qa/tests/test_skill_commands.pyuses it to actually replay this file's commands into a trace and send it throughverify— so an example here that drifts from the CLI or from the event order goes red at commit time, rather than halfway through the next QA run. When adding a block containingux_receipt.pycommands, carry this marker; the test blocks a fence that lacks it.
--grounding-check-file points at a transient JSON that never enters the trace (same nature as --question-surfaces: keep it in /tmp, out of the repository). The tool performs the verbatim-containment comparison itself and writes only the boolean result and a hash into the receipt; the raw text never lands:
{
"candidates": [
{"id": "candidate_0", "grounding": "the engine's own candidate_rules[].grounding sentence"},
{"id": "candidate_1"}
],
"presented_text": "the exact block of text you showed the user"
}
A candidate with no grounding omits the field entirely (like candidate_1) — do not invent a sentence to fill it. That is precisely the half #293 cannot catch and only a human can hold.
The weekly_review route carries one extra opener, and verify enforces it (the trace above is first_review; do not copy the opener into it). When prepare selects weekly_review, show the user the rule agreed last time before the first question and the first card. Two rows differ from the trace above — that opener, and the cash anchor. This trace deliberately shows the other cash shape: a source with no balance row, so the plan came back absent, and the balance was asked for in the same message as a preview card. Where the outcome row lands from there depends on the outcome (#663), because only one of the two recomputes anything:
declined— nothing recomputes, so the card that asked the question is also the settled one, and the row sits after it, exactly as before.provided— always triggersadd-cashand a recompute, so the card the user actually used to choose or skip the rule is the one rendered afterward, never the one that asked. The trace's onestage=previewartifact/card_presentedpair is reserved for that later, settled card, so the row sits before it: recordcash_anchor_checked --cash-outcome providedwhen the user answers, runadd-cashand rerunpreviewon the session it returns — keeping THIS session id for the whole trace, since a receipt records one conversation, not one engine session — and only then record the deferredartifact_generated/card_presentedpair for the card that recompute produced. The first, pre-cash card was real and the user did see it; it simply gets no artifact/presentation row of its own, because it is an intermediate interaction, not the accepted decision artifact. A pair recorded before this row would receipt that superseded card instead, whichverifynow refuses by name.
This trace shows provided. Everything else — questions, answers received, both card stages, rule choice — is copied verbatim, with the preview pair moved to follow the cash row instead of preceding it:
# qa-trace: weekly_review
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route weekly_review \
--adapter validated_widget --question-mode native_options --card-mode widget
# Opener: exactly one row. One more or one fewer both fail. If last time was a skip with no
# rule set, use --memory-kind prior_skip instead.
python3 tools/ux_receipt.py event --session-id <ID> --event memory_presented \
--memory-kind prior_commitment
# If the plan also returned exit_reason / due_revisit, record one row each (same
# --memory-kind flag; these do not count as the opener):
# python3 tools/ux_receipt.py event --session-id <ID> --event memory_presented --memory-kind due_revisit
python3 tools/ux_receipt.py event --session-id <ID> --event question_presented --mode native_options
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received
# The FIRST preview (holdings-only) renders and is shown here, in one message with the
# rule choice and the cash question — a real, user-visible step. #663: it is an
# intermediate interaction, not the accepted decision artifact, so it gets no
# artifact_generated/card_presented row of its own; only the cash answer is recorded now.
# The user gave a balance, so run `review.py add-cash --session-id <ID> --cash <json>` and
# rerun `preview` on the session it returns before recording the next two rows below.
python3 tools/ux_receipt.py event --session-id <ID> --event cash_anchor_checked \
--cash-outcome provided
# The settled card — the one add-cash's rerun preview rendered — is the pair this trace
# records, and the rule choice is shown alongside it.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented --stage preview --mode widget
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
--mode native_options --grounding-check-file <grounding-check.json>
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented --stage final --mode widget
The wrap-up has the same shape as Step 5, except --memory must be pass or fail: a weekly session does not accept not_applicable, and verify --require-owner-verdict refuses it — memory continuity is the entire reason this route exists, so it may not be waived as inapplicable. This block is written out in full rather than pointing back at Step 5 precisely because that difference is the part copying would miss, and it only bites at the moment of archiving:
# qa-trace: weekly_review
python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded \
--finding episode:EP-0NN
python3 tools/ux_receipt.py event --session-id <ID> --event owner_verdict \
--controls pass --card pass --memory pass
python3 tools/ux_receipt.py verify --session-id <ID> \
--require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
That opener is memory continuity itself — was the rule agreed last time actually brought back and reconciled? For route == "weekly_review", ux_receipt.py hard-checks two things: exactly one opener, positioned before the first question_presented / card_presented. Wrong order still fails, because a row backfilled afterwards cannot prove the user saw it at the time.
The snapshot_review route: a declared book, no trade history
Selected when the user has a position table or screenshot and no transaction history. Transcribe it into the envelope in references/data-contract.md (/tmp/fomo-kernel-positions.json, never inside the repository), then walk flows/snapshot-review.md:
python3 engine/review.py prepare --route snapshot_review \
--snapshot-json /tmp/fomo-kernel-positions.json --language en
python3 engine/review.py preview --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
python3 engine/review.py finalize --session-id <ID> --answers /tmp/answers.json --narrative /tmp/narrative.json
Three differences from first_review decide the trace, and all three were read off a real run rather than assumed:
- No cash anchor row. The route's contract does not carry the #357 pre-flight, because the snapshot envelope declares
cashinline (references/ux-receipt.mdsays so, andverifydemandscash_anchor_checkedonly onfirst_review/weekly_review). Recording one here proves nothing that the envelope did not already state. - No question rows. The observed plan came back with
question_queue: []andcard_plan.question_policyof{"min": 0, "max": 0, "route": "snapshot_review"}— the budget is structurally zero, because a snapshot holds no action history to ask about. Do not manufacture a question or a dynamic surface to fill the gap; the flow says so too. - The rule choice is still reached.
previewreturnedcandidate_ruleswith agroundingsentence on each candidate, so--grounding-check-fileis required here exactly as onfirst_review. This is the route's one real control, and it is what--controlsjudges.
Everything else matches first_review: both card stages, artifact before card, --memory not_applicable (a snapshot review has no prior period to carry, and unlike weekly_review this route accepts that value).
# qa-trace: snapshot_review
# 0) Declare capability the moment `prepare` returns. The trace's session id is
# the plan's own session_id.
python3 tools/ux_receipt.py start --session-id <ID> --client claude --route snapshot_review \
--adapter validated_widget --question-mode native_options --card-mode widget
# 1) No cash_anchor_checked and no question_presented on this route — see above.
# The latency marker still belongs here, immediately before `preview`: it is
# what makes the wait until the card appears measurable (#236), and on a route
# that asks nothing it times the authored thesis_updates/narrative going in.
python3 tools/ux_receipt.py event --session-id <ID> --event answers_received
# 2) The preview card, artifact first. The path is preview's own
# `private_card_html_path`.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage preview --artifact-path <preview-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
--stage preview --mode widget
# 3) The rule choice, with its #293 grounding evidence.
python3 tools/ux_receipt.py event --session-id <ID> --event rule_choice_presented \
--mode native_options --grounding-check-file <grounding-check.json>
# 4) The final card after `finalize`. The path is its `private_card_html`.
python3 tools/ux_receipt.py event --session-id <ID> --event artifact_generated \
--stage final --artifact-path <final-card.html>
python3 tools/ux_receipt.py event --session-id <ID> --event card_presented \
--stage final --mode widget
# 5) Wrap up: findings first, verdict last.
python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded \
--finding episode:EP-0NN
# this run genuinely found nothing (a declaration, not an omission):
# python3 tools/ux_receipt.py event --session-id <ID> --event findings_recorded --no-findings
python3 tools/ux_receipt.py event --session-id <ID> --event owner_verdict \
--controls pass --card pass --memory not_applicable
python3 tools/ux_receipt.py verify --session-id <ID> \
--require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
The refresh route: recording the book comes before reviewing it
Once a book exists, a newer holdings view is not a review. prepare --route snapshot_review refuses any declaration the book-update lane would raise a confirmation for, and names the lane (#530). Its {"status": "error"} payload carries this message, observed verbatim on exit code 2:
this holdings view has changes only you can settle before the recorded book can
catch up; run `review.py refresh --snapshot-json ...` first, then review it
So the real journey is composed — record, then review — and it produces two receipts on two routes, never one. Walk flows/book-refresh.md first:
# step 1: read-only. Writes nothing; returns the frozen diff, a summary, and
# pending_confirmations, keyed by a refresh_id.
python3 engine/review.py refresh --snapshot-json /tmp/fomo-kernel-positions.json
# step 3: adopt, passing that refresh_id back verbatim.
python3 engine/review.py refresh --snapshot-json /tmp/fomo-kernel-positions.json \
--answers /tmp/refresh-answers.json
Then come back to the review with the same declaration; the observed rerun of prepare --route snapshot_review succeeded and carried engine_state.snapshot_reconciliation with status: reconciled, because the refresh had already brought the book up to date.
What makes this route's trace different:
- A refresh creates no session, so the trace is keyed by the engine's own
refresh_id(references/ux-receipt.md). Declare capabilities only after step 1 has returned one. - No card events at all.
verifyrefusesartifact_generated,card_presented,widget_attempt_failedandrule_choice_presentedon this route — a card delivery that structurally cannot have happened. What the trace owes instead is a change surface. - The question row depends on what the engine raised, and so does the verdict. Step 1 returns
status: pending_confirmationwith a non-emptypending_confirmations(the observed run raised a disappearance and an appearance), orstatus: readywithpending_confirmations: []when only small, cash, market or currency differences moved. The first shape presents one question covering every raised item — never one per ticker — and its verdict carries--controls pass|fail; the second presents no question and its verdict must carry--controls not_applicable. - That
--controlschoice is not recoverable. Recording--controls passon a refresh that raised nothing is accepted at write time and then rejected byverifywithowner controls verdict must be not_applicable on a refresh trace. The trace is append-only, so the run is void and has to be walked again. Decide it from step 1'spending_confirmations, before the verdict, not after.
# qa-trace: refresh
# 0) Session id = the refresh_id returned by step 1 of the flow above. This lane
# renders no card, so `widget` has nothing to declare and `--adapter
# validated_widget` would be a claim about a surface that does not exist here;
# `native_options` is the honest ceiling on a host with real controls, and a
# host without them declares `--adapter plain_text` and records the question
# below as `--mode plain_text` (the form references/ux-receipt.md shows).
python3 tools/ux_receipt.py start --session-id <refresh_id> --client claude --route refresh \
--adapter native_options --question-mode native_options
# 1) The engine's difference, as you narrated it. change_presented carries only
# its kind: the diff itself holds tickers and share counts, and no trace ever
# holds those.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event change_presented \
--change-kind diff
# 2) Only when step 1 came back `pending_confirmation`: the one question covering
# every raised item. Omit this row entirely on a `ready` plan, and read the
# --controls note above before the verdict.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event question_presented \
--mode native_options
# 3) After the adopt call returns: what was recorded, or that nothing was.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event change_presented \
--change-kind result
# 4) Wrap up. --card not_applicable is required rather than optional: it is the
# positive claim that no card was owed. --change is the load-bearing axis —
# did what the receipt showed match what actually happened to the book.
python3 tools/ux_receipt.py event --session-id <refresh_id> --event findings_recorded \
--finding episode:EP-0NN
python3 tools/ux_receipt.py event --session-id <refresh_id> --event owner_verdict \
--controls pass --card not_applicable --memory not_applicable --change pass
python3 tools/ux_receipt.py verify --session-id <refresh_id> \
--require-recorded-owner-verdict --require-owner-verdict --require-timing-integrity --require-findings
Archive the two receipts separately. The refresh and the review that follows it are one journey and one --case-id, so the second archive is --state-mode continued --parent-run-id <the first run_id> — a fresh review archived beside a refresh it actually continued would lose exactly the lineage #520 added.
Three walkthrough rules (from the 2026-07-20 owner_live audit correction; breaking any one voids that QA session):
Declare capability honestly, and try the widget once per session — with the right tool: the single walkthrough deviation on 2026-07-20 was under-declaring
card_modeswith zero widget attempts. #249's rich HTML card was generated, but the owner saw flat Markdown throughout (the main reason card=fail). A graphical surface must declarewidget: try the widget first, and on failure recordwidget_attempt_failedbefore degrading to Markdown — do not let "the artifact was green" stand in for "it was delivered" again.New lesson, 2026-07-21 (see the #230 comments): trying the widget does not mean grabbing whatever tool sounds like a renderer. Generic chart/dashboard visualization tools (some MCP
show_widget/visualizetools, for instance) usually carry their own design system and will normalize or strip a large third-party<style>block. That is not "the host cannot render rich HTML" — it is the tool behaving as designed, and feeding it the card is using the wrong tool. To verify widget delivery, pick a pipeline that preserves the supplied<style>and HTML as-is without design-system normalization (for example Claude Code's Artifact-style publishing tools: a page of their own, with no external design system applied). Do not treat a tool as equivalent to whatreferences/card-delivery.mdcalls "graphical surface: render a widget from the engine HTML artifact" merely because its name sounds like "widget". This was mis-diagnosed once — "picked the wrong tool" was reported as "the host has no rendering capability", posted to GitHub as a wrong diagnosis, and corrected only afterwards. Confirm the tool's contract (does it preserve the original CSS?) before concluding.This tool-selection detail belongs here and must not be promoted into fomo-kernel's
docs/qa-runbook.md: that document ships with the public product for any stranger who clones it, and deliberately uses host-agnostic language naming no tool ("if the host can render rich content, try it; on failure degrade to canonical Markdown"). That is correct, because an external user's client has none of these Claude MCP tools and a hardcoded tool name means nothing to them. The operational knowledge of "how to tell whether a tool will normalize your CSS" is a Claude-specific MCP-ecosystem detail and belongs only in this skill (fomo-qa, for ting's own maintenance of fomo-kernel, not a public contract). Do not let it slip across while editing the runbook.--languagefollows the conversation language: a Chinese conversation always uses--language zh-TW(stated in the productSKILL.md's Language section; forcingenin the 2026-07-20 mock session caused the mixed-language output in #262).Measure "answered → card": the timestamp gap from
answers_receivedto the previewcard_presentedis the machine wait in seconds, and it must be reported at wrap-up (#236's re-measurement instrument;tools/ux_receipt.py --helpis authoritative for events and timestamps).
The complete event sequence lives in the product's references/interaction-delivery.md (arguments per tools/ux_receipt.py --help — the docs drift occasionally; start --required-question, for instance, no longer exists in the code). **What fomo-qa changes is promoting this step from "t
…(truncated)