| Dimension | Shared | Only in A | Only in B | Overlap |
|---|---|---|---|---|
| Sections | 0 | 5 | 17 | 0% |
| Commands | 1 | 2 | 21 | 4% |
| Section tags | 4 | 1 | 8 | 31% |
What each file covers
Sections
0 shared · 5 only in A · 17 only in B- − Release discipline
- − One feature, one release
- − Commit attribution
- − Public vs private docs
- − Bracket / live-formatting ship checklist
- + AGENTS.md — Claudinho
- + What this is
- + Stack
- + Layout
- + Commands
- + Releasing
- + Commit attribution (all agents)
- + Reviewing PRs (all agents)
- + Codex / GPT specifics
- + Conventions
- + Hard constraints (legal — do not violate)
- + Pre-PR self-review (run before declaring a change "done")
- + Change discipline (the failures that cost #97 twelve rounds)
- + Definition of Done (per user-facing feature, not per PR)
- + Release readiness — run `scripts/release-qa.sh` before tagging
- + Release cadence — batch, don't dot-release per fix
- + Don't
Commands
1 shared · 2 only in A · 21 only in B- − git diff main
- − pnpm -r build
- + npx -y @claudinho/mcp
- + pnpm -F @claudinho/core gen:schedule
- + pnpm install
- + pnpm build
- + pnpm test
- + pnpm typecheck
- + pnpm lint
- + pnpm -F @claudinho/core test
- + biome.json
- + biome migrate
- + pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint
- + git tag vX.Y.Z && git push origin vX.Y.Z
- + pnpm -r publish --provenance
- + gh release create --generate-notes
- + pnpm -F @claudinho/mcp build:mcpb
- + npx
- + pnpm -F @claudinho/<pkg> publish --access public
- + git log
- + gh pr view <n> --json headRefOid,headRefName
- + git rev-parse HEAD
- + gh run watch
- pnpm release:qa
Section tags
4 shared · 1 only in A · 8 only in B- − docs
- + setup
- + test
- + code-style
- + architecture
- + testing-strategy
- + security
- + do-not
- + agent-behaviour
- lint-format
- git-pr
- performance
- deployment
Line diff
arturogarrido/claudinho · .cursor/rules/release-discipline.mdc
@@ −1 @@
1---
2description: Release batching, gitignore policy, and pre-tag checks
3alwaysApply: true
4---
5
6# Release discipline
7
8## One feature, one release
9
10- **One user-facing feature → one minor.** Stack review fixes (tz, dates, guards) into the **same PR** before merge when they belong to the same feature.
11- Do **not** tag npm until CI is green **and** `pnpm release:qa` passes (tripwires at the end must be 0 failed).
12- Before tagging: `git diff main` must not include accidental private paths (`docs/`, `CLAUDE.local.md`, `.env`).
13
14## Commit attribution
15
16Every commit produced with an AI agent: follow **`AGENTS.md` § Commit attribution** — trailer names the agent **and** model in use (e.g. `Co-Authored-By: Cursor (Composer 2.5) <cursoragent@cursor.com>`).
17
18## Public vs private docs
19
20**Tracked (public engineering guides):** `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, README, MCP tool descriptions.
21
22**Gitignored (maintainer-local):** `docs/` (e.g. `docs/AGENTS.internal.md` notebook), `CLAUDE.local.md`.
23
24- Engineering process → update the **public** guides above.
25- Candid history, strategy, or release post-mortems → `docs/` notebook only.
26- **Never** add `!docs/…` exceptions to `.gitignore` without explicit maintainer approval.
27
28## Bracket / live-formatting ship checklist
29
30Before merge **or** release of bracket or kickoff-formatting work:
31
321. Adversarial tests: 0 games, mid-tournament (2/3 played), degraded standings.
332. **Surface parity:** CLI `bracket` + MCP `get_bracket` + `share bracket` all pass `tz` and show calendar dates on multi-week knockouts.
343. **Statusline knockouts (if touched):** indirect-resolve via cached `getKnockoutFixtures` — hot path fail-closed (no 🏳️ on countdown or `live · syncing…`); empty-cache short TTL; `knockout-surface-coverage.test.ts` + `statusline.test.ts` / `refresh.test.ts` green. See `.cursor/rules/surface-parity.mdc`.
354. After `pnpm -r build`, run **`pnpm release:qa`** and eyeball the rendered sections; keep `packages/mcp/test/tools.test.ts` green for MCP arg threading.
36
arturogarrido/claudinho · AGENTS.md
@@ +1 @@
1# AGENTS.md — Claudinho
2
3Guidance for AI coding agents working **in this repository**. (Standard [AGENTS.md](https://agents.md); Claude Code reads it via `CLAUDE.md`.)
4
5## What this is
6
7Claudinho surfaces the 2026 men's football tournament in developer environments: a **CLI**, a **statusline** (Claude Code **and Cursor CLI** — `init claude`/`init cursor` one-step setup), an **MCP server** (also a **Cursor Marketplace plugin** + cursor.directory listing), a **score-aware hook** (Claude Code `UserPromptSubmit`), live scores/fixtures and **cumulative group standings** (from the provider's standings feed), a **knockout bracket**, read-only **prediction-market signals** (Polymarket odds — informational only), and **shareable terminal snippets** (`claudinho share` — copy-pasteable match **and standings** cards), and a **fuzzy team resolver** (`get_team` / `claudinho team` — a nation name or code → FIFA code + flag + group; the only **offline** MCP tool, `openWorldHint:false`; agents call it to resolve a user's team name into the code the other tools need). **Planned:** a desktop **notifier**, a precomputed **AI pundit**, and a small edge **gateway** that polls a data feed once for everyone.
8
9## Stack
10
11- TypeScript, Node ≥ 20, ES modules
12- pnpm workspaces (monorepo) · tsup (build) · vitest (test) · Biome (lint)
13- MCP: `@modelcontextprotocol/sdk`
14- Gateway (_planned_, not yet built): Cloudflare Workers + KV + D1 (`services/gateway`)
15
16## Layout
17
18| Path | Package | Role |
19|---|---|---|
20| `packages/core` | `@claudinho/core` | domain model, provider adapters, normalize, tz, emoji flags, i18n, static schedule, bracket, validators |
21| `packages/cli` | `@claudinho/cli` | the `claudinho` binary (CLI + statusline + hook + cache/refresher) |
22| `packages/mcp` | `@claudinho/mcp` | stdio MCP server |
23| `.cursor-plugin/plugin.json` + `mcp.json` (repo root) | — | Cursor Marketplace **plugin** — wraps `@claudinho/mcp` for cursor.com/marketplace. Plugin version is decoupled from npm (`npx -y @claudinho/mcp` = latest); guarded by `packages/mcp/test/cursor-plugin.test.ts`. |
24| `packages/core/src/data/schedule.2026.json` | — | static fixtures, bundled into clients (regenerate via `pnpm -F @claudinho/core gen:schedule`) |
25| `packages/core/src/markets` | — | prediction-market sidecar: `MarketSignal` model, `isReliableMarketSignal` gate, copy bank, `PolymarketProvider` (read-only public data; event slugs auto-derived per fixture), `mapping.2026.json` (slug overrides only, ships empty) |
26| `packages/core/src/share` | — | shareable-snippet formatters: `formatShareSnippet` (match cards) and `formatShareTable` (group-standings cards); disclaimer non-optional, no ANSI, English-only copy in v1 (except `share bracket`, localized) |
27| `.cursor/rules/*.mdc` | — | Cursor rules — the public, contributor-facing engineering guardrails (release discipline, surface parity, bracket/schedule invariants) |
28| `packages/notifier` | `@claudinho/notifier` | _planned_ — `claudinho watch` daemon |
29| `services/gateway` | — | _planned_ — edge API + SSE + cron |
30
31## Commands
32
33- `pnpm install` — install deps
34- `pnpm build` / `pnpm test` / `pnpm typecheck` / `pnpm lint` — across all packages (`lint` = Biome)
35- `pnpm -F @claudinho/core test` — operate on a single package
36- `pnpm release:qa` — pre-tag surface renderer (see "Release readiness")
37
38**Bumping `@biomejs/biome`?** Nothing to do — `biome.json`'s `$schema` points at
39`./node_modules/@biomejs/biome/configuration_schema.json`, so it resolves to whatever version is
40installed and is correct by construction after any bump. This replaced a version-pinned
41`https://biomejs.dev/schemas/<version>/schema.json` URL that drifted on **six** consecutive bumps
42(Dependabot rewrites only the root `package.json`, never `biome.json`). Each drift made Biome emit a
43*"configuration schema version does not match the CLI version"* diagnostic on every lint run — three
44of them (2.5.0 / 2.5.1 / 2.5.3) landed on `main` that way, since an info-level diagnostic fails
45nothing. Only from the guard test onward did drift become a red build, which is what CI then caught.
46`packages/core/test/biome-schema.test.ts` now guards the *shape*: it fails if anyone reintroduces a
47pinned URL, or if a Biome upgrade moves the bundled schema file. (If you ever do edit `biome.json`,
48hand-edit it — `biome migrate` reformats the whole config to tabs.)
49
50## Releasing
51
52Releases ship via `.github/workflows/publish.yml` on a `v*` tag, using npm **trusted
53publishing** (OIDC) — **no `NPM_TOKEN`**. A trusted publisher is configured on npm for all
54three packages (`@claudinho/core` · `cli` · `mcp`), all published **with provenance**.
55
56To cut a release:
57
581. Bump the version in the **three** `package.json` files (`packages/{cli,mcp,core}/package.json`).
59 The cli `--version` and MCP `serverInfo.version` are injected from package.json at build time
60 (tsup `define` → `process.env.CLAUDINHO_VERSION`), so there are no source constants to touch.
61 **Also bump `packages/mcp/mcpb/manifest.json`** — the `.mcpb` desktop-extension manifest carries
62 its own `version`; a vitest guard (`packages/mcp/test/manifest.test.ts`) fails if it drifts.
632. `pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint` (CI re-runs these). **For any
64 user-facing change, also run `pnpm release:qa`** and eyeball every surface against the live feed
65 before tagging (see "Release readiness").
663. Commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The workflow gates (build/test/lint +
67 tag==version), then `pnpm -r publish --provenance` ships all three via OIDC (versions already on
68 npm are skipped) and auto-creates the GitHub Release (`gh release create --generate-notes`).
69
70**MCP-affecting releases** (anything that changes a tool's shape or description) also bump
71`packages/mcp/server.json` and re-publish to the MCP Registry — from the repo root, pass the path
72(`mcp-publisher publish packages/mcp/server.json`), since `mcp-publisher` defaults to `./server.json`
73in the cwd and ours isn't at the root.
74
75**Adding or changing an MCP tool:** every tool declares an `outputSchema` and returns
76`structuredContent` (`packages/mcp/src/server.ts`). A **new** tool must be added to
77`OUTPUT_SCHEMAS` *and* to `packages/mcp/test/output-schema.test.ts` (which parses each handler's
78`data` — healthy **and** degraded — against its schema). The output schemas are **hand-mirrored**
79from the `@claudinho/core` types and kept permissive (`.passthrough()` on nested objects); the
80`.strict()` top-level guard test is the safety net that catches schema/handler drift, so keep it
81green. `build:mcpb` injects these schemas into the Smithery-scored `.mcpb` manifest.
82
83**Smithery (`.mcpb`) re-publish** — optional, and only when you want the Smithery listing to
84track a new version: `pnpm -F @claudinho/mcp build:mcpb` (stages `mcpb/manifest.json` + the tsup
85server + its external deps, smoke-tests it, then `mcpb pack` → `packages/mcp/dist/claudinho-<v>.mcpb`),
86then `smithery mcp publish <that .mcpb> -n arturogarrido/claudinho`. The bundle pins the server at
87that version (it runs the bundled code, not `npx`-latest), so the listing is a snapshot until you
88re-publish.
89
90**Lessons (learned the hard way):**
91- npm deprecated classic "Automation" tokens — use **trusted publishing**, not a token.
92- A brand-new package can't have a trusted publisher pre-configured; its **first** publish must be
93 a manual `pnpm -F @claudinho/<pkg> publish --access public` (enter OTP), then add its trusted
94 publisher for later releases.
95- Account 2FA set to "Authorization and writes" forces an OTP that CI can't supply; trusted
96 publishing (OIDC) sidesteps it entirely.
97
98## Commit attribution (all agents)
99
100This repo is worked on by multiple AI coding agents. Commits produced with one are
101**co-authored by the agent and the model** that wrote them, so `git log` (and the GitHub
102contributors view) shows which agent — and which model — did the work. Add a trailer in the
103**last paragraph** of the commit message, and credit the same in the PR body:
104
105```
106Co-Authored-By: <Agent> (<Model>) <agent-no-reply-email>
107```
108
109Use the model **actually in use**, not a hardcoded one. Examples, one per agent (Claude Code
110uses Anthropic's no-reply address; Cursor and Codex follow the same pattern with their own):
111
112```
113Co-Authored-By: Claude Code (Opus 4.8) <noreply@anthropic.com>
114Co-Authored-By: Cursor (Composer 2.5) <...>
115Co-Authored-By: Codex (GPT-5) <noreply@openai.com>
116```
117
118## Reviewing PRs (all agents)
119
120- For PR reviews, first verify the local checkout matches the PR head before running gates:
121 `gh pr view <n> --json headRefOid,headRefName` and `git rev-parse HEAD`. If they differ,
122 check out or fast-forward the PR branch before reviewing.
123- Review-only tasks are read-only unless the user explicitly asks for fixes. Lead with findings,
124 classify them P1/P2/P3, and include tight file/line references. If there are no findings, say
125 that plainly and list the checks run plus any residual risk.
126- Do not treat a previous review, memory, or local branch name as current truth. Re-check the PR
127 SHA, merge state, and CI status at the end of the review.
128
129## Codex / GPT specifics
130
131- Codex has no separate sidecar guide in this repo; `AGENTS.md` is its source of truth. Follow
132 the shared sections here: "Reviewing PRs", "Pre-PR self-review", "Release readiness", and
133 "Commit attribution".
134- If Codex makes a commit, use the actual GPT model in the `Co-Authored-By` trailer; the example
135 above is illustrative, not a hardcoded model name.
136
137## Conventions
138
139- Shared domain types live in `@claudinho/core` — never duplicate them.
140- Every data vendor implements the `ProviderAdapter` interface — keep providers swappable. An adapter **FETCHES**; it must not interpret. Turning a payload into a domain type happens in `packages/core/src/trust/` and nowhere else (`packages/core/src/trust/espn.ts` is the model), because feed strings reach terminals, share cards, and Claude's context via the hook. Cache readers call the **same** constructors: when the live and cache paths had separate rules, every fix landed on one of them and left the other open — the asymmetry behind most of the security findings in #96. `core/test/trust-parity.test.ts` asserts the two agree. `ProviderAdapter` keeps its plain-array contract. Record-level refusal is local: malformed, duplicate, or truncated records are omitted while readable siblings remain usable and attributed to the provider. A transport/JSON failure, an unreadable envelope, or a non-empty provider list with no usable records reaches the domain's degraded fallback. Never turn one refused record into a batch-wide outage.
141- **Standings expected scope and static fallback compatibility are separate contracts.** `expectedStandingsGroups` says which tables must exist for a provider result to be complete; it never authorizes bundled teams. `standingsFallbackGroups` explicitly says which groups may use the bundled roster. The default World Cup adapter advertises A–L for both. A custom-competition adapter may declare its expected letters but leaves bundled fallback unset, so an outage or omitted expected table returns empty + degraded and can never inject World Cup teams. After a successful open-scope fetch, an absent group means "no such group." An all-groups read uses one fallback verdict because `StandingsResult` cannot honestly represent mixed live and static provenance.
142- **Text has ROLES, not one universal cleaner.** A human label is prose (no controls, no format characters, no emoji — bounded by display columns AND code points); an identifier is checked against an exact grammar; a timestamp is re-emitted canonically; a flag is **generated** from the nation, never read from a payload or a cache file. That last one is load-bearing: while flags travelled through the text filter, the filter needed an emoji carve-out, and a carve-out without its own grammar is a covert channel (TAG characters, variation selectors and ZWJ each rode through it in turn — a `🏴` plus 42 tag characters is one 2-column glyph spelling a full instruction sentence).
143- **A rejection says which KIND it is.** `ParseResult` distinguishes `valid` / `definitive-none` / `malformed` / `ambiguous` / `unresolved`. For per-item market resolution, `valid`, `definitive-none` and `ambiguous` are stable conclusions and may be cached for a TTL; `malformed` and `unresolved` must not become definitive negative market results. A successfully fetched provider batch is different: share its readable prefix for the coalescing TTL even when a sibling row was malformed, because refetching identical bytes cannot heal that row and must not starve valid siblings.
144- **Market completeness reaches the renderer.** `cachedMarketSignals` / `marketSignalsFor` return `{ signals, complete }`; default-on annotations, dedicated market commands/tools, share snippets, and JSON/structured output must retain that verdict. A complete empty batch may render "no signal". An incomplete batch renders an explicit unavailable/incomplete notice and carries `marketComplete:false` or `complete:false`; never collapse it to an empty `Map`.
145- **Bound the WORK, not just the output.** Collections are sliced before the per-record work, never after (`takeBounded`), and a surface reports `total`/`shown`/`truncated` from ONE `BoundedList` rather than recomputing counts per call site.
146- Static data (schedule, groups, flags) ships bundled in clients; only **live state** hits the network.
147- **Standings come from the provider's standings feed, NOT computed from a match window.** The bundled schedule is a resultless skeleton, and clients only fetch a ±1-day live window — so deriving a table from those matches yields a *wrong, partial* table mid-tournament (this was a real bug: groups not playing that day read all-zeros). `table`/`get_standings`/`standings://` go through core `getStandings` → optional `adapter.fetchStandings()` (authoritative cumulative table from ESPN's standings endpoint — the SAME endpoint `fetchGroupMap` already hits, so no new egress). It **fails closed** with no attribution: the default World Cup scope may show a static roster-at-zero, while a competition with no compatible bundled roster returns empty + degraded. `computeStandings` (match-derived) remains for the compatible static fallback only.
148- **`CLAUDINHO_COMPETITION` is a deliberate keeper — do not remove it.** It points the live fetch at another ESPN competition (e.g. `fifa.friendly`) and is woven through both the live fetch and the **hot-path cache key** (the cache is competition-keyed). It looks dormant during the World Cup but is load-bearing — it's the seam for following other tournaments.
149- **The bundled schedule is a resultless skeleton.** No scores/status, no confirmed nations in knockout slots. `sanitizeBundledFixture` restores topology placeholders; `gen:schedule` **fails loud** if any knockout fixture carries a real nation flag. Advancement comes only from the live overlay — clients never invent it from static JSON.
150- **The live fixture's pairing wins over the static topology's winner-refs.** `buildBracketView`/`resolveSlot` resolve a knockout slot from the ESPN fixture ESPN actually serves for that match (`liveParticipant`), and only fall back to projecting a winner from the bundled `winner`/`loser` topology refs when that fixture is **absent from the merged set** (degraded feed). This is deliberate: the bundled winner-ref indices (parsed from ESPN's placeholder slot labels at generation time) do **not** reliably correspond to ESPN's actual R32→R16 feeder assignment, so projecting from them rendered **wrong R16 pairings** (v0.8.16 P1: "Paraguay vs Mexico" instead of the real ties). The topology is now structure/labels + a degraded-only fallback; the pairing is ESPN's. Guarded by the `P1 GUARD` case in `bracket-resolve.test.ts` (feeder ref disagrees with the live fixture → live wins).
151- **Knockout/team-facing surfaces MUST live-resolve — never read the skeleton.** Because the bundle's knockout slots are 🏳️ placeholders (above), *every* team-facing surface must reach the live overlay (`getBracket` / `getNextFixtureForTeam`) to show real nations; a surface that reads the static bundle is **silently** blind to a confirmed tie (no crash, just a stale placeholder). This is Claudinho's most recurring bug: the *same* root cause shipped as v0.8.2 (R32 seeds), v0.8.6 (third-place slots), and v0.8.7 (next fixture) — three hotfixes, three different surfaces. **The surface list (keep in sync):** CLI `bracket` · `next` · `share bracket` · `share next`; MCP `get_bracket` · `get_next_fixture` · `get_share_snippet{bracket,next}`. The **statusline** can't fetch on the hot path (<150ms, cache-only), so it live-resolves *indirectly*: the cold-path refresher caches resolved knockout fixtures (`getKnockoutFixtures` → `CacheState.fixtures`) and the statusline reads them, **failing closed to `⚽ —`** (never a 🏳️ placeholder leak) when the cache lacks the pairing (v0.8.8; v0.8.9 adds empty-cache short TTL at phase boundaries and drops unresolved matchups from `live · syncing…`). **`cmdPrompt` and `cmdHook`** both spawn fixtures refresh in knockout phase. **Executable guard:** `packages/{cli,mcp}/test/knockout-surface-coverage.test.ts` pins one fake resolved tie (MEX vs ECU) and asserts every surface renders the real nations (the statusline from a seeded cache). **Add any new team-facing surface to that test** (and to `.cursor/rules/surface-parity.mdc`). **Deliberate exception — the MCP `fixtures://{date}` resource:** it is labeled "Static fixture list" and serves the bundled skeleton by design (a resource URI carries no timezone and gets no live overlay, so knockout slots stay 🏳️ placeholders there); agents needing live-resolved pairings use `get_today`/`get_bracket`. It is the only team-facing surface allowed to read the skeleton directly.
152- Market signals use a separate `MarketProvider` interface (not `ProviderAdapter`). The Polymarket event slug is **derived per fixture** (`fifwc-{home}-{away}-{date}`), so most matches resolve with no mapping; `mapping.2026.json` holds slug **overrides only** (ships empty) and validation **fails closed**. Market-facing copy is English-only in v1 (the approved legal copy bank lives in `core/src/markets/format.ts`). Two derivation quirks (both fail-closed, both `release:qa`-tripwired): Polymarket slugs by the **host-local date** (try the UTC date + prior day — `deriveEventSlugs`), and it abbreviates some nations differently from their FIFA code (`POLYMARKET_TOKEN` alias table, e.g. `NED→nld`, `COD→cdr` — used by both slug derivation and outcome-market matching). Candidate-slug fetches honor the enrichment **deadline between candidates** (not just between fixtures), so alias fan-out (up to 8 slugs) never blocks default-on rendering.
153- **The statusline and hook are English-only by design** — a deliberate carve-out from the four-locale rule. Both are single-line, latency-bound ambient surfaces whose few fixed tokens ("live · syncing…", "in 2d 4h", the hook's context label) stay EN; the interactive commands localize, the two ambient surfaces don't.
154- **Shareable snippets** (`claudinho share`, the MCP `get_share_snippet` tool, `core/src/share`) are pure, deterministic **plain-text** artifacts (no ANSI — they get pasted): English-only copy in v1 **except `share bracket`** (localized en/es/pt/fr via `ShareBracketOptions.locale`; the non-affiliation disclaimer + hashtag stay EN as fixed strings), market lines reuse the approved copy bank verbatim (`marketBlock`/`marketLine`, never hand-composed), and the non-affiliation disclaimer is **non-optional** (only the hashtag and install cue are toggleable). They use the same reliable market gate as `today`/`match` and are **never** on the statusline/hook hot path. **`share table <GROUP>`** produces a standings card — facts + emoji flags only, **no market line**; a degraded (roster-only) card carries an explicit not-live notice so it can't paste as real.
155- **Star CTAs are human-interactive-only.** The npm→GitHub conversion nudges — `claudinho star`, the every-Nth dimmed footer on `today`/`live`/`next`/`table`/`bracket`/`match`/`team`, the post-`init` line, and the README callouts — live ONLY on interactive human surfaces. They are **never** on the hot path (statusline `prompt` / `hook`), **never** in `--json` or piped output (`process.stdout.isTTY`-gated, with a `CLAUDINHO_NO_STAR` opt-out), and **never** in MCP tool output/descriptions (that's agent context — a "star us" there wastes tokens and can leak to end users). The footer counter (`packages/cli/src/starNudge.ts`) is best-effort and never throws: a CTA must not break, slow, or pollute a command.
156
157## Hard constraints (legal — do not violate)
158
159- **Facts + emoji flags only.** Never add team crests, kits, player photos/likenesses, broadcast footage, or FIFA/Anthropic logos or wordmarks.
160- Keep the dual disclaimer — *"Not affiliated with FIFA or Anthropic"* — on user-facing surfaces.
161- Attribute data providers; respect their rate limits.
162- **Prediction-market data is read-only and informational.** Public market data only — no wallet/auth/CLOB/trading endpoints, no outbound market links (`url` stays `null`). Never frame odds as betting/trading advice (no "bet/wager/value/edge/lock"); keep the *"informational only"* caveat and attribute the provider (Polymarket). Market signals are a **sidecar** — never embedded in `Match`, and never read on the statusline/hook hot path (a regression test enforces this).
163
164## Pre-PR self-review (run before declaring a change "done")
165
166This is the lens an external reviewer uses — apply it yourself first. For changes
167touching money/legal/external APIs, also run an **independent adversarial pass**
168(e.g. a reviewer subagent with fresh eyes on the diff) and self-classify any
169findings **P1/P2/P3**.
170
1711. **Verify external contracts against ground truth.** For any new API/integration,
172 fetch a *real* response and confirm the parser **and the test fixtures** match it.
173 Never ship against an assumed payload shape — green tests built on a wrong fixture
174 prove nothing.
1752. **Apply the change to *every* surface.** Enumerate them: CLI (text **and** `--json`),
176 MCP (structured `data` **and** text), statusline/hook, share, and the READMEs. A behavior
177 that lands on 3 of 4 surfaces is a bug — and "every surface" means each surface's *args*
178 (tz/locale/flags) are threaded, not just that the surface exists.
1793. **Audit the whole touched area against the Hard Constraints — including pre-existing
180 code, not just the diff** (e.g. "attribute data providers" applies to *every*
181 provider, not only the one you added).
1824. **Adversarial failure-mode pass per new code path:** empty / missing / malformed
183 input; transient vs. permanent error (never cache a transient failure as a real
184 "no result"); duplicate / ambiguous data; timeout / deadline / concurrency. Default
185 to **fail-closed**.
1865. **State the worst-case latency/cost of any default-on path** under realistic load
187 (e.g. "N sequential fetches × T timeout") and bound it (deadline + cache).
1886. **Sync the meta in the same change:** READMEs, the Cursor rules (`.cursor/rules/`), MCP
189 tool descriptions, and release guards (`publish.yml`, pinned tool versions). Flag any
190 claim that went stale.
191
192## Change discipline (the failures that cost #97 twelve rounds)
193
194Nothing here is new — it is the rules above, made unskippable. Each line is a
195mistake repeated at least twice in one PR, several of them *after* being written
196down.
197
198**Changing a shared rule**
199
200- **Put the rule where every path reaches it, then delete the other copy.** Not "add
201 the check at the site the report mentioned". #97 fixed the knockout winner rule in
202 the ESPN parser while the cache path had none — inside the PR whose whole thesis is
203 that one value must not have two readers.
204- **Grep for siblings before calling a class closed.** Every single-instance fix in
205 #97 had two or three: `updatedAt` had four other timestamps, the statusline had the
206 hook, `parseCachedMatches` had both ESPN constructors.
207
208**Tests**
209
210- **Make it fail before trusting it.** Revert the rule; green means it pins nothing.
211 About a third of #97's tests first passed for the wrong reason.
212- **Pin the CALL, not just the function.** Delete the call site and confirm red. Two
213 #97 tests pinned a helper and stayed green when its only caller was removed — the
214 second written one round after that lesson was recorded.
215- **Never assert wall-clock time.** Three timing tests failed under load or on CI, and
216 each "fix" was a new constant. If the property has an observable consequence, assert
217 that: put a valid item just past the cap and prove it is never reached.
218- **Escape invisible characters in fixtures.** Written literally they are lost in
219 transit, and the test then passes on plain ASCII while claiming otherwise.
220
221**Before saying it is done**
222
223- **Run the gates AND read the output.** #97 pushed a lint error to a repo that gates
224 on lint, and separately broke a `release:qa` tripwire — both times the output was
225 produced and not read.
226- **Diff real-feed output against the base branch**, key order included, for anything
227 claiming to be a refactor.
228- **Wait for CI on the SHA you pushed.** `gh run watch` on a queued run returns
229 success; check `headSha` matches.
230
231## Definition of Done (per user-facing feature, not per PR)
232
233The Pre-PR rubric above is per *change*. A feature that spans several PRs also needs a
234**feature-level** acceptance gate — the bracket feature became a core release plus four
235reactive dot-releases because each gap (ambiguous dates, missing host-nation flags, a
236dropped `tz` on MCP `get_bracket`) was found by *using* the feature after it was already
237live. Before implementing a user-facing feature, write 3–5 acceptance criteria **from the
238user's point of view** and don't call it done until each holds on a real terminal.
239**Put them in the PR description before the first commit, together with an explicit
240statement of what the change does NOT cover.** PR #97 skipped this and took twelve
241review rounds: with no written finish line every round ended at "I fixed what was
242reported" and the next round moved it, and with no stated boundary, findings that were
243equally true of `main` arrived as blockers instead of as issues. The criteria:
244
245- The output is **unambiguous** to read (e.g. "which calendar day is this match?" across a 3-week span).
246- Every entity renders **consistently with the rest of the product** (host nations show flags like every other team; no static/placeholder leaks; the resultless invariant holds).
247- It behaves across **all timezones, all four locales** (en/es/pt/fr), and **every surface** (CLI text + `--json`, MCP `data` + text, share) — not just en/local/CLI.
248- It **fails closed** (degraded feed → honest TBD/notice, never an invented or stale fact).
249
250Scope these up front so the feature ships whole, not in dot-release pieces.
251
252## Release readiness — run `scripts/release-qa.sh` before tagging
253
254"Test it on a real terminal first" is executable: **`scripts/release-qa.sh`** (`pnpm release:qa`)
255renders *every* user-facing surface against the **live feed, across two timezones and all four
256locales**, and ends with tripwires for known regression classes (bracket shows a calendar date,
257`tz` is actually threaded, disclaimers intact; it SKIPs rather than fails on a degraded/unreachable
258feed, so a network blip never blocks a release). Build, run it, **read the output**, then tag. It
259does not replace the eyeball — its job is to put every surface in front of you so nothing ships
260unseen. (It covers CLI/share rendering; MCP arg-threading is guarded by
261`packages/mcp/test/tools.test.ts` — keep that green too.)
262
263## Release cadence — batch, don't dot-release per fix
264
265Two kinds of release, and only one is urgent:
266
267- **Hotfix-now** — live data *correctness* only (a wrong score/standing *during* a match,
268 or a feed outage rendering as authoritative). Ship immediately.
269- **Everything else** — UX, polish, cosmetics, follow-on sub-features — **batch** onto the
270 working branch and release as a single bump. Stack review fixes for the same feature into the
271 same PR before merge.
272
273Every release carries real toil (a multi-file version bump, and for MCP-affecting changes an MCP
274Registry re-publish). Fewer, fuller releases cut that directly. When unsure, accumulate.
275
276## Don't
277
278- Don't put API keys in client packages — keys live **only** in the gateway.
279- Don't block the statusline hot path on the network — read from the local cache (<150ms).
280
@@ −1 +1 @@
1−---
2−description: Release batching, gitignore policy, and pre-tag checks
3−alwaysApply: true
4−---
1+# AGENTS.md — Claudinho
52
6−# Release discipline
3+Guidance for AI coding agents working **in this repository**. (Standard [AGENTS.md](https://agents.md); Claude Code reads it via `CLAUDE.md`.)
74
8−## One feature, one release
5+## What this is
96
10−- **One user-facing feature → one minor.** Stack review fixes (tz, dates, guards) into the **same PR** before merge when they belong to the same feature.
11−- Do **not** tag npm until CI is green **and** `pnpm release:qa` passes (tripwires at the end must be 0 failed).
12−- Before tagging: `git diff main` must not include accidental private paths (`docs/`, `CLAUDE.local.md`, `.env`).
7+Claudinho surfaces the 2026 men's football tournament in developer environments: a **CLI**, a **statusline** (Claude Code **and Cursor CLI** — `init claude`/`init cursor` one-step setup), an **MCP server** (also a **Cursor Marketplace plugin** + cursor.directory listing), a **score-aware hook** (Claude Code `UserPromptSubmit`), live scores/fixtures and **cumulative group standings** (from the provider's standings feed), a **knockout bracket**, read-only **prediction-market signals** (Polymarket odds — informational only), and **shareable terminal snippets** (`claudinho share` — copy-pasteable match **and standings** cards), and a **fuzzy team resolver** (`get_team` / `claudinho team` — a nation name or code → FIFA code + flag + group; the only **offline** MCP tool, `openWorldHint:false`; agents call it to resolve a user's team name into the code the other tools need). **Planned:** a desktop **notifier**, a precomputed **AI pundit**, and a small edge **gateway** that polls a data feed once for everyone.
138
14−## Commit attribution
9+## Stack
1510
16−Every commit produced with an AI agent: follow **`AGENTS.md` § Commit attribution** — trailer names the agent **and** model in use (e.g. `Co-Authored-By: Cursor (Composer 2.5) <cursoragent@cursor.com>`).
11+- TypeScript, Node ≥ 20, ES modules
12+- pnpm workspaces (monorepo) · tsup (build) · vitest (test) · Biome (lint)
13+- MCP: `@modelcontextprotocol/sdk`
14+- Gateway (_planned_, not yet built): Cloudflare Workers + KV + D1 (`services/gateway`)
1715
18−## Public vs private docs
16+## Layout
1917
20−**Tracked (public engineering guides):** `AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, README, MCP tool descriptions.
18+| Path | Package | Role |
19+|---|---|---|
20+| `packages/core` | `@claudinho/core` | domain model, provider adapters, normalize, tz, emoji flags, i18n, static schedule, bracket, validators |
21+| `packages/cli` | `@claudinho/cli` | the `claudinho` binary (CLI + statusline + hook + cache/refresher) |
22+| `packages/mcp` | `@claudinho/mcp` | stdio MCP server |
23+| `.cursor-plugin/plugin.json` + `mcp.json` (repo root) | — | Cursor Marketplace **plugin** — wraps `@claudinho/mcp` for cursor.com/marketplace. Plugin version is decoupled from npm (`npx -y @claudinho/mcp` = latest); guarded by `packages/mcp/test/cursor-plugin.test.ts`. |
24+| `packages/core/src/data/schedule.2026.json` | — | static fixtures, bundled into clients (regenerate via `pnpm -F @claudinho/core gen:schedule`) |
25+| `packages/core/src/markets` | — | prediction-market sidecar: `MarketSignal` model, `isReliableMarketSignal` gate, copy bank, `PolymarketProvider` (read-only public data; event slugs auto-derived per fixture), `mapping.2026.json` (slug overrides only, ships empty) |
26+| `packages/core/src/share` | — | shareable-snippet formatters: `formatShareSnippet` (match cards) and `formatShareTable` (group-standings cards); disclaimer non-optional, no ANSI, English-only copy in v1 (except `share bracket`, localized) |
27+| `.cursor/rules/*.mdc` | — | Cursor rules — the public, contributor-facing engineering guardrails (release discipline, surface parity, bracket/schedule invariants) |
28+| `packages/notifier` | `@claudinho/notifier` | _planned_ — `claudinho watch` daemon |
29+| `services/gateway` | — | _planned_ — edge API + SSE + cron |
2130
22−**Gitignored (maintainer-local):** `docs/` (e.g. `docs/AGENTS.internal.md` notebook), `CLAUDE.local.md`.
31+## Commands
2332
24−- Engineering process → update the **public** guides above.
25−- Candid history, strategy, or release post-mortems → `docs/` notebook only.
26−- **Never** add `!docs/…` exceptions to `.gitignore` without explicit maintainer approval.
33+- `pnpm install` — install deps
34+- `pnpm build` / `pnpm test` / `pnpm typecheck` / `pnpm lint` — across all packages (`lint` = Biome)
35+- `pnpm -F @claudinho/core test` — operate on a single package
36+- `pnpm release:qa` — pre-tag surface renderer (see "Release readiness")
2737
28−## Bracket / live-formatting ship checklist
38+**Bumping `@biomejs/biome`?** Nothing to do — `biome.json`'s `$schema` points at
39+`./node_modules/@biomejs/biome/configuration_schema.json`, so it resolves to whatever version is
40+installed and is correct by construction after any bump. This replaced a version-pinned
41+`https://biomejs.dev/schemas/<version>/schema.json` URL that drifted on **six** consecutive bumps
42+(Dependabot rewrites only the root `package.json`, never `biome.json`). Each drift made Biome emit a
43+*"configuration schema version does not match the CLI version"* diagnostic on every lint run — three
44+of them (2.5.0 / 2.5.1 / 2.5.3) landed on `main` that way, since an info-level diagnostic fails
45+nothing. Only from the guard test onward did drift become a red build, which is what CI then caught.
46+`packages/core/test/biome-schema.test.ts` now guards the *shape*: it fails if anyone reintroduces a
47+pinned URL, or if a Biome upgrade moves the bundled schema file. (If you ever do edit `biome.json`,
48+hand-edit it — `biome migrate` reformats the whole config to tabs.)
2949
30−Before merge **or** release of bracket or kickoff-formatting work:
50+## Releasing
3151
32−1. Adversarial tests: 0 games, mid-tournament (2/3 played), degraded standings.
33−2. **Surface parity:** CLI `bracket` + MCP `get_bracket` + `share bracket` all pass `tz` and show calendar dates on multi-week knockouts.
34−3. **Statusline knockouts (if touched):** indirect-resolve via cached `getKnockoutFixtures` — hot path fail-closed (no 🏳️ on countdown or `live · syncing…`); empty-cache short TTL; `knockout-surface-coverage.test.ts` + `statusline.test.ts` / `refresh.test.ts` green. See `.cursor/rules/surface-parity.mdc`.
35−4. After `pnpm -r build`, run **`pnpm release:qa`** and eyeball the rendered sections; keep `packages/mcp/test/tools.test.ts` green for MCP arg threading.
52+Releases ship via `.github/workflows/publish.yml` on a `v*` tag, using npm **trusted
53+publishing** (OIDC) — **no `NPM_TOKEN`**. A trusted publisher is configured on npm for all
54+three packages (`@claudinho/core` · `cli` · `mcp`), all published **with provenance**.
55+
56+To cut a release:
57+
58+1. Bump the version in the **three** `package.json` files (`packages/{cli,mcp,core}/package.json`).
59+ The cli `--version` and MCP `serverInfo.version` are injected from package.json at build time
60+ (tsup `define` → `process.env.CLAUDINHO_VERSION`), so there are no source constants to touch.
61+ **Also bump `packages/mcp/mcpb/manifest.json`** — the `.mcpb` desktop-extension manifest carries
62+ its own `version`; a vitest guard (`packages/mcp/test/manifest.test.ts`) fails if it drifts.
63+2. `pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint` (CI re-runs these). **For any
64+ user-facing change, also run `pnpm release:qa`** and eyeball every surface against the live feed
65+ before tagging (see "Release readiness").
66+3. Commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The workflow gates (build/test/lint +
67+ tag==version), then `pnpm -r publish --provenance` ships all three via OIDC (versions already on
68+ npm are skipped) and auto-creates the GitHub Release (`gh release create --generate-notes`).
69+
70+**MCP-affecting releases** (anything that changes a tool's shape or description) also bump
71+`packages/mcp/server.json` and re-publish to the MCP Registry — from the repo root, pass the path
72+(`mcp-publisher publish packages/mcp/server.json`), since `mcp-publisher` defaults to `./server.json`
73+in the cwd and ours isn't at the root.
74+
75+**Adding or changing an MCP tool:** every tool declares an `outputSchema` and returns
76+`structuredContent` (`packages/mcp/src/server.ts`). A **new** tool must be added to
77+`OUTPUT_SCHEMAS` *and* to `packages/mcp/test/output-schema.test.ts` (which parses each handler's
78+`data` — healthy **and** degraded — against its schema). The output schemas are **hand-mirrored**
79+from the `@claudinho/core` types and kept permissive (`.passthrough()` on nested objects); the
80+`.strict()` top-level guard test is the safety net that catches schema/handler drift, so keep it
81+green. `build:mcpb` injects these schemas into the Smithery-scored `.mcpb` manifest.
82+
83+**Smithery (`.mcpb`) re-publish** — optional, and only when you want the Smithery listing to
84+track a new version: `pnpm -F @claudinho/mcp build:mcpb` (stages `mcpb/manifest.json` + the tsup
85+server + its external deps, smoke-tests it, then `mcpb pack` → `packages/mcp/dist/claudinho-<v>.mcpb`),
86+then `smithery mcp publish <that .mcpb> -n arturogarrido/claudinho`. The bundle pins the server at
87+that version (it runs the bundled code, not `npx`-latest), so the listing is a snapshot until you
88+re-publish.
89+
90+**Lessons (learned the hard way):**
91+- npm deprecated classic "Automation" tokens — use **trusted publishing**, not a token.
92+- A brand-new package can't have a trusted publisher pre-configured; its **first** publish must be
93+ a manual `pnpm -F @claudinho/<pkg> publish --access public` (enter OTP), then add its trusted
94+ publisher for later releases.
95+- Account 2FA set to "Authorization and writes" forces an OTP that CI can't supply; trusted
96+ publishing (OIDC) sidesteps it entirely.
97+
98+## Commit attribution (all agents)
99+
100+This repo is worked on by multiple AI coding agents. Commits produced with one are
101+**co-authored by the agent and the model** that wrote them, so `git log` (and the GitHub
102+contributors view) shows which agent — and which model — did the work. Add a trailer in the
103+**last paragraph** of the commit message, and credit the same in the PR body:
104+
105+```
106+Co-Authored-By: <Agent> (<Model>) <agent-no-reply-email>
107+```
108+
109+Use the model **actually in use**, not a hardcoded one. Examples, one per agent (Claude Code
110+uses Anthropic's no-reply address; Cursor and Codex follow the same pattern with their own):
111+
112+```
113+Co-Authored-By: Claude Code (Opus 4.8) <noreply@anthropic.com>
114+Co-Authored-By: Cursor (Composer 2.5) <...>
115+Co-Authored-By: Codex (GPT-5) <noreply@openai.com>
116+```
117+
118+## Reviewing PRs (all agents)
119+
120+- For PR reviews, first verify the local checkout matches the PR head before running gates:
121+ `gh pr view <n> --json headRefOid,headRefName` and `git rev-parse HEAD`. If they differ,
122+ check out or fast-forward the PR branch before reviewing.
123+- Review-only tasks are read-only unless the user explicitly asks for fixes. Lead with findings,
124+ classify them P1/P2/P3, and include tight file/line references. If there are no findings, say
125+ that plainly and list the checks run plus any residual risk.
126+- Do not treat a previous review, memory, or local branch name as current truth. Re-check the PR
127+ SHA, merge state, and CI status at the end of the review.
128+
129+## Codex / GPT specifics
130+
131+- Codex has no separate sidecar guide in this repo; `AGENTS.md` is its source of truth. Follow
132+ the shared sections here: "Reviewing PRs", "Pre-PR self-review", "Release readiness", and
133+ "Commit attribution".
134+- If Codex makes a commit, use the actual GPT model in the `Co-Authored-By` trailer; the example
135+ above is illustrative, not a hardcoded model name.
136+
137+## Conventions
138+
139+- Shared domain types live in `@claudinho/core` — never duplicate them.
140+- Every data vendor implements the `ProviderAdapter` interface — keep providers swappable. An adapter **FETCHES**; it must not interpret. Turning a payload into a domain type happens in `packages/core/src/trust/` and nowhere else (`packages/core/src/trust/espn.ts` is the model), because feed strings reach terminals, share cards, and Claude's context via the hook. Cache readers call the **same** constructors: when the live and cache paths had separate rules, every fix landed on one of them and left the other open — the asymmetry behind most of the security findings in #96. `core/test/trust-parity.test.ts` asserts the two agree. `ProviderAdapter` keeps its plain-array contract. Record-level refusal is local: malformed, duplicate, or truncated records are omitted while readable siblings remain usable and attributed to the provider. A transport/JSON failure, an unreadable envelope, or a non-empty provider list with no usable records reaches the domain's degraded fallback. Never turn one refused record into a batch-wide outage.
141+- **Standings expected scope and static fallback compatibility are separate contracts.** `expectedStandingsGroups` says which tables must exist for a provider result to be complete; it never authorizes bundled teams. `standingsFallbackGroups` explicitly says which groups may use the bundled roster. The default World Cup adapter advertises A–L for both. A custom-competition adapter may declare its expected letters but leaves bundled fallback unset, so an outage or omitted expected table returns empty + degraded and can never inject World Cup teams. After a successful open-scope fetch, an absent group means "no such group." An all-groups read uses one fallback verdict because `StandingsResult` cannot honestly represent mixed live and static provenance.
142+- **Text has ROLES, not one universal cleaner.** A human label is prose (no controls, no format characters, no emoji — bounded by display columns AND code points); an identifier is checked against an exact grammar; a timestamp is re-emitted canonically; a flag is **generated** from the nation, never read from a payload or a cache file. That last one is load-bearing: while flags travelled through the text filter, the filter needed an emoji carve-out, and a carve-out without its own grammar is a covert channel (TAG characters, variation selectors and ZWJ each rode through it in turn — a `🏴` plus 42 tag characters is one 2-column glyph spelling a full instruction sentence).
143+- **A rejection says which KIND it is.** `ParseResult` distinguishes `valid` / `definitive-none` / `malformed` / `ambiguous` / `unresolved`. For per-item market resolution, `valid`, `definitive-none` and `ambiguous` are stable conclusions and may be cached for a TTL; `malformed` and `unresolved` must not become definitive negative market results. A successfully fetched provider batch is different: share its readable prefix for the coalescing TTL even when a sibling row was malformed, because refetching identical bytes cannot heal that row and must not starve valid siblings.
144+- **Market completeness reaches the renderer.** `cachedMarketSignals` / `marketSignalsFor` return `{ signals, complete }`; default-on annotations, dedicated market commands/tools, share snippets, and JSON/structured output must retain that verdict. A complete empty batch may render "no signal". An incomplete batch renders an explicit unavailable/incomplete notice and carries `marketComplete:false` or `complete:false`; never collapse it to an empty `Map`.
145+- **Bound the WORK, not just the output.** Collections are sliced before the per-record work, never after (`takeBounded`), and a surface reports `total`/`shown`/`truncated` from ONE `BoundedList` rather than recomputing counts per call site.
146+- Static data (schedule, groups, flags) ships bundled in clients; only **live state** hits the network.
147+- **Standings come from the provider's standings feed, NOT computed from a match window.** The bundled schedule is a resultless skeleton, and clients only fetch a ±1-day live window — so deriving a table from those matches yields a *wrong, partial* table mid-tournament (this was a real bug: groups not playing that day read all-zeros). `table`/`get_standings`/`standings://` go through core `getStandings` → optional `adapter.fetchStandings()` (authoritative cumulative table from ESPN's standings endpoint — the SAME endpoint `fetchGroupMap` already hits, so no new egress). It **fails closed** with no attribution: the default World Cup scope may show a static roster-at-zero, while a competition with no compatible bundled roster returns empty + degraded. `computeStandings` (match-derived) remains for the compatible static fallback only.
148+- **`CLAUDINHO_COMPETITION` is a deliberate keeper — do not remove it.** It points the live fetch at another ESPN competition (e.g. `fifa.friendly`) and is woven through both the live fetch and the **hot-path cache key** (the cache is competition-keyed). It looks dormant during the World Cup but is load-bearing — it's the seam for following other tournaments.
149+- **The bundled schedule is a resultless skeleton.** No scores/status, no confirmed nations in knockout slots. `sanitizeBundledFixture` restores topology placeholders; `gen:schedule` **fails loud** if any knockout fixture carries a real nation flag. Advancement comes only from the live overlay — clients never invent it from static JSON.
150+- **The live fixture's pairing wins over the static topology's winner-refs.** `buildBracketView`/`resolveSlot` resolve a knockout slot from the ESPN fixture ESPN actually serves for that match (`liveParticipant`), and only fall back to projecting a winner from the bundled `winner`/`loser` topology refs when that fixture is **absent from the merged set** (degraded feed). This is deliberate: the bundled winner-ref indices (parsed from ESPN's placeholder slot labels at generation time) do **not** reliably correspond to ESPN's actual R32→R16 feeder assignment, so projecting from them rendered **wrong R16 pairings** (v0.8.16 P1: "Paraguay vs Mexico" instead of the real ties). The topology is now structure/labels + a degraded-only fallback; the pairing is ESPN's. Guarded by the `P1 GUARD` case in `bracket-resolve.test.ts` (feeder ref disagrees with the live fixture → live wins).
151+- **Knockout/team-facing surfaces MUST live-resolve — never read the skeleton.** Because the bundle's knockout slots are 🏳️ placeholders (above), *every* team-facing surface must reach the live overlay (`getBracket` / `getNextFixtureForTeam`) to show real nations; a surface that reads the static bundle is **silently** blind to a confirmed tie (no crash, just a stale placeholder). This is Claudinho's most recurring bug: the *same* root cause shipped as v0.8.2 (R32 seeds), v0.8.6 (third-place slots), and v0.8.7 (next fixture) — three hotfixes, three different surfaces. **The surface list (keep in sync):** CLI `bracket` · `next` · `share bracket` · `share next`; MCP `get_bracket` · `get_next_fixture` · `get_share_snippet{bracket,next}`. The **statusline** can't fetch on the hot path (<150ms, cache-only), so it live-resolves *indirectly*: the cold-path refresher caches resolved knockout fixtures (`getKnockoutFixtures` → `CacheState.fixtures`) and the statusline reads them, **failing closed to `⚽ —`** (never a 🏳️ placeholder leak) when the cache lacks the pairing (v0.8.8; v0.8.9 adds empty-cache short TTL at phase boundaries and drops unresolved matchups from `live · syncing…`). **`cmdPrompt` and `cmdHook`** both spawn fixtures refresh in knockout phase. **Executable guard:** `packages/{cli,mcp}/test/knockout-surface-coverage.test.ts` pins one fake resolved tie (MEX vs ECU) and asserts every surface renders the real nations (the statusline from a seeded cache). **Add any new team-facing surface to that test** (and to `.cursor/rules/surface-parity.mdc`). **Deliberate exception — the MCP `fixtures://{date}` resource:** it is labeled "Static fixture list" and serves the bundled skeleton by design (a resource URI carries no timezone and gets no live overlay, so knockout slots stay 🏳️ placeholders there); agents needing live-resolved pairings use `get_today`/`get_bracket`. It is the only team-facing surface allowed to read the skeleton directly.
152+- Market signals use a separate `MarketProvider` interface (not `ProviderAdapter`). The Polymarket event slug is **derived per fixture** (`fifwc-{home}-{away}-{date}`), so most matches resolve with no mapping; `mapping.2026.json` holds slug **overrides only** (ships empty) and validation **fails closed**. Market-facing copy is English-only in v1 (the approved legal copy bank lives in `core/src/markets/format.ts`). Two derivation quirks (both fail-closed, both `release:qa`-tripwired): Polymarket slugs by the **host-local date** (try the UTC date + prior day — `deriveEventSlugs`), and it abbreviates some nations differently from their FIFA code (`POLYMARKET_TOKEN` alias table, e.g. `NED→nld`, `COD→cdr` — used by both slug derivation and outcome-market matching). Candidate-slug fetches honor the enrichment **deadline between candidates** (not just between fixtures), so alias fan-out (up to 8 slugs) never blocks default-on rendering.
153+- **The statusline and hook are English-only by design** — a deliberate carve-out from the four-locale rule. Both are single-line, latency-bound ambient surfaces whose few fixed tokens ("live · syncing…", "in 2d 4h", the hook's context label) stay EN; the interactive commands localize, the two ambient surfaces don't.
154+- **Shareable snippets** (`claudinho share`, the MCP `get_share_snippet` tool, `core/src/share`) are pure, deterministic **plain-text** artifacts (no ANSI — they get pasted): English-only copy in v1 **except `share bracket`** (localized en/es/pt/fr via `ShareBracketOptions.locale`; the non-affiliation disclaimer + hashtag stay EN as fixed strings), market lines reuse the approved copy bank verbatim (`marketBlock`/`marketLine`, never hand-composed), and the non-affiliation disclaimer is **non-optional** (only the hashtag and install cue are toggleable). They use the same reliable market gate as `today`/`match` and are **never** on the statusline/hook hot path. **`share table <GROUP>`** produces a standings card — facts + emoji flags only, **no market line**; a degraded (roster-only) card carries an explicit not-live notice so it can't paste as real.
155+- **Star CTAs are human-interactive-only.** The npm→GitHub conversion nudges — `claudinho star`, the every-Nth dimmed footer on `today`/`live`/`next`/`table`/`bracket`/`match`/`team`, the post-`init` line, and the README callouts — live ONLY on interactive human surfaces. They are **never** on the hot path (statusline `prompt` / `hook`), **never** in `--json` or piped output (`process.stdout.isTTY`-gated, with a `CLAUDINHO_NO_STAR` opt-out), and **never** in MCP tool output/descriptions (that's agent context — a "star us" there wastes tokens and can leak to end users). The footer counter (`packages/cli/src/starNudge.ts`) is best-effort and never throws: a CTA must not break, slow, or pollute a command.
156+
157+## Hard constraints (legal — do not violate)
158+
159+- **Facts + emoji flags only.** Never add team crests, kits, player photos/likenesses, broadcast footage, or FIFA/Anthropic logos or wordmarks.
160+- Keep the dual disclaimer — *"Not affiliated with FIFA or Anthropic"* — on user-facing surfaces.
161+- Attribute data providers; respect their rate limits.
162+- **Prediction-market data is read-only and informational.** Public market data only — no wallet/auth/CLOB/trading endpoints, no outbound market links (`url` stays `null`). Never frame odds as betting/trading advice (no "bet/wager/value/edge/lock"); keep the *"informational only"* caveat and attribute the provider (Polymarket). Market signals are a **sidecar** — never embedded in `Match`, and never read on the statusline/hook hot path (a regression test enforces this).
163+
164+## Pre-PR self-review (run before declaring a change "done")
165+
166+This is the lens an external reviewer uses — apply it yourself first. For changes
167+touching money/legal/external APIs, also run an **independent adversarial pass**
168+(e.g. a reviewer subagent with fresh eyes on the diff) and self-classify any
169+findings **P1/P2/P3**.
170+
171+1. **Verify external contracts against ground truth.** For any new API/integration,
172+ fetch a *real* response and confirm the parser **and the test fixtures** match it.
173+ Never ship against an assumed payload shape — green tests built on a wrong fixture
174+ prove nothing.
175+2. **Apply the change to *every* surface.** Enumerate them: CLI (text **and** `--json`),
176+ MCP (structured `data` **and** text), statusline/hook, share, and the READMEs. A behavior
177+ that lands on 3 of 4 surfaces is a bug — and "every surface" means each surface's *args*
178+ (tz/locale/flags) are threaded, not just that the surface exists.
179+3. **Audit the whole touched area against the Hard Constraints — including pre-existing
180+ code, not just the diff** (e.g. "attribute data providers" applies to *every*
181+ provider, not only the one you added).
182+4. **Adversarial failure-mode pass per new code path:** empty / missing / malformed
183+ input; transient vs. permanent error (never cache a transient failure as a real
184+ "no result"); duplicate / ambiguous data; timeout / deadline / concurrency. Default
185+ to **fail-closed**.
186+5. **State the worst-case latency/cost of any default-on path** under realistic load
187+ (e.g. "N sequential fetches × T timeout") and bound it (deadline + cache).
188+6. **Sync the meta in the same change:** READMEs, the Cursor rules (`.cursor/rules/`), MCP
189+ tool descriptions, and release guards (`publish.yml`, pinned tool versions). Flag any
190+ claim that went stale.
191+
192+## Change discipline (the failures that cost #97 twelve rounds)
193+
194+Nothing here is new — it is the rules above, made unskippable. Each line is a
195+mistake repeated at least twice in one PR, several of them *after* being written
196+down.
197+
198+**Changing a shared rule**
199+
200+- **Put the rule where every path reaches it, then delete the other copy.** Not "add
201+ the check at the site the report mentioned". #97 fixed the knockout winner rule in
202+ the ESPN parser while the cache path had none — inside the PR whose whole thesis is
203+ that one value must not have two readers.
204+- **Grep for siblings before calling a class closed.** Every single-instance fix in
205+ #97 had two or three: `updatedAt` had four other timestamps, the statusline had the
206+ hook, `parseCachedMatches` had both ESPN constructors.
207+
208+**Tests**
209+
210+- **Make it fail before trusting it.** Revert the rule; green means it pins nothing.
211+ About a third of #97's tests first passed for the wrong reason.
212+- **Pin the CALL, not just the function.** Delete the call site and confirm red. Two
213+ #97 tests pinned a helper and stayed green when its only caller was removed — the
214+ second written one round after that lesson was recorded.
215+- **Never assert wall-clock time.** Three timing tests failed under load or on CI, and
216+ each "fix" was a new constant. If the property has an observable consequence, assert
217+ that: put a valid item just past the cap and prove it is never reached.
218+- **Escape invisible characters in fixtures.** Written literally they are lost in
219+ transit, and the test then passes on plain ASCII while claiming otherwise.
220+
221+**Before saying it is done**
222+
223+- **Run the gates AND read the output.** #97 pushed a lint error to a repo that gates
224+ on lint, and separately broke a `release:qa` tripwire — both times the output was
225+ produced and not read.
226+- **Diff real-feed output against the base branch**, key order included, for anything
227+ claiming to be a refactor.
228+- **Wait for CI on the SHA you pushed.** `gh run watch` on a queued run returns
229+ success; check `headSha` matches.
230+
231+## Definition of Done (per user-facing feature, not per PR)
232+
233+The Pre-PR rubric above is per *change*. A feature that spans several PRs also needs a
234+**feature-level** acceptance gate — the bracket feature became a core release plus four
235+reactive dot-releases because each gap (ambiguous dates, missing host-nation flags, a
236+dropped `tz` on MCP `get_bracket`) was found by *using* the feature after it was already
237+live. Before implementing a user-facing feature, write 3–5 acceptance criteria **from the
238+user's point of view** and don't call it done until each holds on a real terminal.
239+**Put them in the PR description before the first commit, together with an explicit
240+statement of what the change does NOT cover.** PR #97 skipped this and took twelve
241+review rounds: with no written finish line every round ended at "I fixed what was
242+reported" and the next round moved it, and with no stated boundary, findings that were
243+equally true of `main` arrived as blockers instead of as issues. The criteria:
244+
245+- The output is **unambiguous** to read (e.g. "which calendar day is this match?" across a 3-week span).
246+- Every entity renders **consistently with the rest of the product** (host nations show flags like every other team; no static/placeholder leaks; the resultless invariant holds).
247+- It behaves across **all timezones, all four locales** (en/es/pt/fr), and **every surface** (CLI text + `--json`, MCP `data` + text, share) — not just en/local/CLI.
248+- It **fails closed** (degraded feed → honest TBD/notice, never an invented or stale fact).
249+
250+Scope these up front so the feature ships whole, not in dot-release pieces.
251+
252+## Release readiness — run `scripts/release-qa.sh` before tagging
253+
254+"Test it on a real terminal first" is executable: **`scripts/release-qa.sh`** (`pnpm release:qa`)
255+renders *every* user-facing surface against the **live feed, across two timezones and all four
256+locales**, and ends with tripwires for known regression classes (bracket shows a calendar date,
257+`tz` is actually threaded, disclaimers intact; it SKIPs rather than fails on a degraded/unreachable
258+feed, so a network blip never blocks a release). Build, run it, **read the output**, then tag. It
259+does not replace the eyeball — its job is to put every surface in front of you so nothing ships
260+unseen. (It covers CLI/share rendering; MCP arg-threading is guarded by
261+`packages/mcp/test/tools.test.ts` — keep that green too.)
262+
263+## Release cadence — batch, don't dot-release per fix
264+
265+Two kinds of release, and only one is urgent:
266+
267+- **Hotfix-now** — live data *correctness* only (a wrong score/standing *during* a match,
268+ or a feed outage rendering as authoritative). Ship immediately.
269+- **Everything else** — UX, polish, cosmetics, follow-on sub-features — **batch** onto the
270+ working branch and release as a single bump. Stack review fixes for the same feature into the
271+ same PR before merge.
272+
273+Every release carries real toil (a multi-file version bump, and for MCP-affecting changes an MCP
274+Registry re-publish). Fewer, fuller releases cut that directly. When unsure, accumulate.
275+
276+## Don't
277+
278+- Don't put API keys in client packages — keys live **only** in the gateway.
279+- Don't block the statusline hot path on the network — read from the local cache (<150ms).
36280
