AGENTS.md · git:20261018.6b7a1c4 · 2026-10-18 · sha256 b3578a42a4df3b25
AGENTS.md git:20261018.6b7a1c4A
Immutable. This exact content never changes and is served at /api/v1/blob/b3578a42a4df3b25.
# AGENTS.md — Claudinho
Guidance for AI coding agents working **in this repository**. (Standard [AGENTS.md](https://agents.md); Claude Code reads it via `CLAUDE.md`.)
## Index
One line per section of this file: what it is for and when a task loads it. The router skill, `.claude/skills/claudinho-mode/SKILL.md`, names the playbook for each task kind and the guide files under `guides/conventions/` to load with it, by the area a task touches; a "Conventions" bullet names its own guide on its `Narration:` line.
- **Working agreement**: what completing a task means, when to ask, how to report; every task reads it first.
- **What this is**: the product in one paragraph; read when a task needs its scope.
- **Stack**: the languages and tools; read for a build or dependency question.
- **Layout**: where each package and table lives; read before opening a file you have not opened.
- **Commands**: the commands, the canary, the README matrix generator; read before running one.
- **Validation scope**: the checks each kind of change needs, with the private-document boundary check; read before calling any change ready.
- **Releasing**: the version bump, the publish workflow, the Registry record, the `.mcpb` bundle; read for a release.
- **Commit attribution (all agents)**: the co-author trailer; read before the first commit.
- **Reviewing PRs (all agents)**: a review's checkout, its threat model and how its findings are written; read for a review.
- **Codex / GPT specifics**: notes for that agent family; read only when one of its agents runs.
- **Conventions**: the engineering rules, one bullet each, a bullet whose narration moved ending with a `Narration:` line naming its file under `guides/conventions/`; read the bullets of every area a change touches.
- **Rules and their enforcers**: each failure class, what refuses it and what that does not prove; read before calling a class closed.
- **Hard constraints (legal — do not violate)**: the legal limits; read for any user-facing, copy or market change.
- **Pre-PR self-review (run before declaring a change "done")**: the six questions an outside reviewer asks; read before opening a PR.
- **Change discipline**: how a shared rule, a regression test and a "done" are made; read for every code change.
- **Definition of Done (per user-facing feature, not per PR)**: acceptance criteria before code; read for a user-facing feature.
- **Release readiness — run `scripts/release-qa.sh` before tagging**: the live pass over every surface; read for a release or a user-facing change.
- **Release cadence — batch, don't dot-release per fix**: a hotfix against a batched release; read when deciding to release.
- **Don't**: the short list of what is never done; read once per session.
## Working agreement
- Complete the requested work through validation and self-review. Resolve routine implementation
choices from the code and state material assumptions; ask when missing information would change
the intended behavior or scope. Continue independent work while awaiting an answer.
- Plans, audits, and reviews are read-only unless the user asks for edits. Preserve unrelated local
changes. Apply existing authorization without asking again; a local edit does not by itself
authorize a push, merge, tag, or publish.
- Read the files and applicable rules needed for the task. This file is the canonical repository
guide; `CLAUDE.md` adds Claude-specific setup, and `.cursor/rules/` mirrors focused rules. Apply
direct user instructions over repository workflow defaults, within system/developer constraints.
If a rule blocks authorized work, cite the exact rule and explain the conflict.
- Treat memory, prior reviews, and model recommendations as context. Verify changing facts against
current source, configuration, and the live PR head before relying on them. Keep durable project
rules here rather than copying them into personal memory or another agent guide.
- Preserve the user's selected model and effort. Both are set and checked in the coding client; a
model recommendation in these guides does not change that setting or prove a model performs
better on this project.
- Use an independent reviewer for the cases required under "Pre-PR self-review". Give each reviewer
a bounded scope and reconcile their evidence. Other delegation follows the user's instructions
and the coding client's rules.
- Report the outcome, checks actually run, and unresolved limitations concisely. For reviews, lead
with actionable findings and exact file/line references. Distinguish a skipped check from a pass.
## What this is
Claudinho surfaces live football for the competition you follow (the World Cup, the Premier League, LALIGA, the Champions League and 12 more) 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 **Claude Code plugin** (the band above the prompt, score toasts and the prompt context, on `claudinho ambient --json`; a match pane, `/claudinho match`, on `claudinho match <id> --json`'s `rows`, its `lines` for an older CLI; the goal moments: what each toast says, the goal and full-time celebration with the mascot, the band's bold score, on the view's `lineParts`, `finished` and per-record `pinnedSide`, `names` and `lastGoal`; in the reader's language, on the view's `lang` and `words`, each ambient run (the band's, `/claudinho`'s) handed `--lang`, the match runs too once any view states `lang`), a **score-aware hook** (Claude Code `UserPromptSubmit`), a structured twin of both for a program, `claudinho ambient --json`, 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** for the World Cup roster (`get_team` / `claudinho team` — a nation name or code → FIFA code + flag + group; one of the two **offline** MCP tools, `openWorldHint:false`; agents use it for the 3-letter code `get_market_signal` takes; a club competition's team tools take a club's name or code directly, resolved against that competition's roster). It answers for **sixteen supported competitions** (one table, `packages/core/src/supported.ts`), one per request, chosen by `--competition <alias>`, then `CLAUDINHO_COMPETITION`, then the user's saved choice (`claudinho follow <alias>`, a config file) on the CLI, and by the `competition` argument on every MCP tool but `get_team` with the same two fallbacks; with nothing chosen there is no competition, never a default; `list_competitions` (the other offline MCP tool) lists them with what each offers. **Planned:** a desktop **notifier**, a precomputed **AI pundit**, and a small edge **gateway** that polls a data feed once for everyone.
## Stack
- TypeScript, Node ≥ 20, ES modules
- pnpm workspaces (monorepo) · tsup (build) · vitest (test) · Biome (lint-only; formatter disabled)
- MCP: `@modelcontextprotocol/sdk`
- Gateway (_planned_, not yet built): Cloudflare Workers + KV + D1 (`services/gateway`)
## Layout
| Path | Package | Role |
|---|---|---|
| `packages/core` | `@claudinho/core` | domain model, provider adapters, normalize, tz, emoji flags, i18n, static schedule, bracket, validators; the commentary banks (`flavor.ts`: a phrase per moment, `flavorsFor` keeps a list's rows distinct) and the team rally cries (`rally.ts`, keyed by the provider id), both pinned to the approved fixtures `packages/core/test/fixtures/{flavor-bank,rally-cries}.approved.json` |
| `packages/cli` | `@claudinho/cli` | the `claudinho` binary (CLI + statusline + hook + `ambient` + cache/refresher) |
| `packages/mcp` | `@claudinho/mcp` | stdio MCP server |
| `.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`. |
| `packages/plugin` + `.claude-plugin/marketplace.json` (repo root) | — | the Claude Code **plugin**: one hooks module (`hooks/register.tsx`: the band, the toasts, the prompt context and the `/claudinho` slash command, the band's line on demand, on `claudinho ambient --json`; the match pane `/claudinho match` opens, on `claudinho match <id> --json`'s `rows`, its `lines` for an older CLI), its manifest and its contract (`types/index.d.ts`); no `package.json` (not a workspace package, never published to npm), installed from the root marketplace file (`/plugin install claudinho --marketplace arturogarrido/claudinho`); its tests (`hooks/*.test.tsx`) run by `claude plugin test`, in CI's `plugin` job, which validates both folders under the pinned Claude Code CLI |
| `packages/core/src/supported.ts` | — | the supported set: ONE table of the sixteen competitions (slug, alias, name, teams' kind, kind, a league's season name, standings shape, bracket and markets capabilities, edition cadence); every written fact of a competition derives from it (`deriveTables`: `TEAM_KIND`, `COMPETITION_KIND`, `SEASON_SLUG`, `STANDINGS_SHAPE`, `NO_BRACKET`, `MARKET_COMPETITIONS`, all frozen; it refuses a row offering a bracket for any competition but the bundled one) or reads the rows (`bracketCapability`, `capabilitiesOf`, the canary's competitions and cadences, the README matrix via `pnpm gen:readme-matrix`, rendered with core's `capabilitiesOf`) |
| `packages/core/src/data/schedule.2026.json` | — | static fixtures, bundled into clients (regenerate via `pnpm -F @claudinho/core gen:schedule`) |
| `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) |
| `packages/core/src/share` | — | shareable snippets: the card builders (`cards.ts`: what a live, date, next, match, table or bracket card says, assembled once for the CLI and MCP) and the formatters (`formatShareSnippet`, `formatShareTable`, `formatShareBracket`); disclaimer non-optional, no ANSI, English-only copy in v1 (except `share bracket`, the verdict sentences on any card, the horizon, window, "none read" and outage sentences of an empty next, match, live or date card, and a date card's count of rows its read did not serve, which are localized); every card's title names its competition and its run cue selects it, the World Cup's too (`npx @claudinho/cli --competition <alias> ...`) |
| `.cursor/rules/*.mdc` | — | Cursor rules — the public, contributor-facing engineering guardrails (release discipline, surface parity, bracket/schedule invariants) |
| `packages/notifier` | `@claudinho/notifier` | _planned_ — `claudinho watch` daemon |
| `services/gateway` | — | _planned_ — edge API + SSE + cron |
## Commands
- `pnpm install` — install deps
- `pnpm build` / `pnpm test` / `pnpm typecheck` / `pnpm lint` — across all packages (`lint` = Biome, no formatter)
- `pnpm -F @claudinho/core test` — operate on a single package
- `pnpm release:qa` — pre-tag surface renderer (see "Release readiness")
- `pnpm gen:readme-matrix` — after a build, rewrites the root README's capability matrix (between `<!-- competitions:start -->` and `<!-- competitions:end -->`) from the supported table; `packages/core/test/readme-matrix.test.ts` fails when the committed block differs
- `pnpm canary` — after a build, asks the REAL feed, for every row of the supported table (core's `SUPPORTED`, with each row's edition cadence), every request form the adapter has (the default scoreboard, one day, the three-day window the live and dated reads use, the bundled bracket's span, the tables, and, off the bundled competition, discovery's schedule ahead: the month requests `getScheduleAhead` makes, each month judged by its own window and discovery's account of the whole required to be whole), through the adapter, and names the kind of problem: a refused request form or a payload the parsers cannot fully read is red; a block or an outage is neutral and reported as a warning, because the canary saw nothing (a block also ends the run: the provider said stop) (`scripts/espn-canary.mjs`). What it checks is listed in the script's header; it does not replace the parsers' own tests. `.github/workflows/espn-canary.yml` runs it daily; it is unbadged and gates nothing. A red run means the feed changed, not that the build is broken. A fetch method added to the adapter must be asked there (a test fails otherwise).
**Bumping `@biomejs/biome`?** Nothing to do — `biome.json`'s `$schema` points at
`./node_modules/@biomejs/biome/configuration_schema.json`, so it follows the installed version.
Keep that local path: `packages/core/test/biome-schema.test.ts` rejects a pinned URL or a missing
bundled schema. Hand-edit `biome.json` when necessary; `biome migrate` reformats it to tabs.
## Validation scope
- **Prose and agent instructions only:** review the diff, check local links and stated contracts,
run `git diff --check`, and run the [private-document boundary check](#private-document-boundary-check)
below. No application build or live-feed QA is needed unless executable
configuration, commands, package contents, or product behavior also change.
- **Code, dependencies, or executable configuration:** iterate with affected tests, then run
`pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint` before declaring the change ready.
`scripts/gate.sh` is the local form of that gate with CI's smokes and guards (one verdict per
step, `--commit <message-file>` committing only when every step is ok, or, with
`--allow-offline-audit`, when the audit alone was skipped offline (the commit line says so and
the exit stays nonzero)); it does not run `pnpm release:qa`, does not fetch the live feed, and
does not replace CI's Node 20, Windows and macOS jobs, which `scripts/push-and-watch.sh`
enforces on the pushed SHA.
Build first because CLI/MCP checks use core's generated output. Run the relevant CI smoke and
packaging checks; MCP contract changes also require the
[base/head `tools/list` comparison](CONTRIBUTING.md#comparing-mcp-tool-contracts) and
`pnpm -F @claudinho/mcp smoke:stdio`.
- **User-facing behavior and releases:** also run `pnpm release:qa` after build and inspect the
output. Keep the "Release readiness" and feature acceptance gates below; a skipped live-feed
check is not verified behavior.
The `verify-claudinho` skill (`.claude/skills/verify-claudinho/SKILL.md`) and its control CLI,
`node scripts/verify.mjs`, are the offline inner loop: one surface of the built binary driven in
an environment built from scratch, offline unless told otherwise, with the evidence of every
child kept; the control CLI replaces neither CI's jobs nor the live pass, `pnpm release:qa`.
- Add tests for changed behavior and failure modes. Do not add tests that merely repeat prose or
implementation details. Once required checks pass, rerun or broaden them only for a subsequent
change, a failure, or an unresolved concern. After a push, verify CI on that exact SHA.
### Private-document boundary check
Run this from the repository root. It needs only Node and Git, prints the scan results, and exits
nonzero for a tracked private document or a reference to one. `scripts/private-doc-refs.mjs` only
exports helpers; running that file directly does not scan anything.
```bash
node --input-type=module <<'NODE'
import { execFileSync } from 'node:child_process';
import { scanTrackedFiles } from './scripts/private-doc-refs.mjs';
const trackedPrivateFiles = execFileSync('git', ['ls-files', '-z', '--', 'docs/'], { encoding: 'utf8' })
.split('\0').filter(Boolean);
const result = scanTrackedFiles(process.cwd());
console.log(JSON.stringify({ ...result, trackedPrivateFiles }, null, 2));
if (result.leaks.length || trackedPrivateFiles.length) process.exitCode = 1;
NODE
```
`pnpm check:pack` also runs the reference scanner and dry-runs package creation. Use it after a
build for packaging validation; a pass without a build does not prove the required artifacts exist.
## Releasing
Releases ship via `.github/workflows/publish.yml` on a `v*` tag, using npm **trusted
publishing** (OIDC) — **no `NPM_TOKEN`**. A trusted publisher is configured on npm for all
three packages (`@claudinho/core` · `cli` · `mcp`), all published **with provenance**.
To cut a release:
1. Bump the version in the **three** `package.json` files (`packages/{cli,mcp,core}/package.json`).
The cli `--version` and MCP `serverInfo.version` are injected from package.json at build time
(tsup `define` → `process.env.CLAUDINHO_VERSION`), so there are no source constants to touch.
**Also bump `packages/mcp/mcpb/manifest.json`** — the `.mcpb` desktop-extension manifest carries
its own `version`; a vitest guard (`packages/mcp/test/manifest.test.ts`) fails if it drifts.
2. `pnpm -r build && pnpm -r typecheck && pnpm -r test && pnpm lint` (CI re-runs these). **For any
user-facing change, also run `pnpm release:qa`** and eyeball every surface against the live feed
before tagging (see "Release readiness").
3. Commit, then `git tag vX.Y.Z && git push origin vX.Y.Z`. The workflow gates (build/test/lint +
tag==version), then `pnpm -r publish --provenance` ships all three via OIDC (versions already on
npm are skipped) and auto-creates the GitHub Release (`gh release create --generate-notes`);
a second job, `mcp-registry`, then publishes the tag's record to the official MCP Registry.
**The MCP Registry record publishes automatically on every tag.** The `mcp-registry` job in
`publish.yml` runs after the npm publish succeeded, waits until npm serves the version (`npm view`
to an 8-minute deadline, every probe bounded at 20 seconds, so the step ends by about 8m40s and the
publisher's retries are spent on Registry errors), authenticates with GitHub Actions OIDC
(`mcp-publisher login github-oidc` — no token, no device flow; the Registry JWT lives five minutes,
which is why one minted on a laptop always lapsed between releases), publishes
`packages/mcp/server.json`, and verifies the record. It skips a version the Registry already has
(records are immutable; a duplicate is a hard 400), so a re-run is safe. `server.json` is bumped on
every release regardless (the vitest guard pins it to `package.json`), so nothing about the Registry is
manual any more. If the job ever fails (Registry outage), re-run it alone with
`gh run rerun <id> --failed`, or fall back to the manual path from the repo root: `mcp-publisher login
github` (device flow), then `mcp-publisher publish packages/mcp/server.json` — pass the path, since
`mcp-publisher` defaults to `./server.json` in the cwd and ours isn't at the root. The `mcp-publisher`
binary is pinned by version + sha256 in the workflow; bump both together from the Registry's releases.
**MCP-affecting releases** (anything that changes a tool's shape or description) still call for the
`.mcpb`/Smithery decision below — that bundle is a snapshot and the one distribution step left manual.
**Adding or changing an MCP tool:** every tool declares an `outputSchema` and returns
`structuredContent` (`packages/mcp/src/server.ts`). A **new** tool must be added to
`OUTPUT_SCHEMAS` *and* to `packages/mcp/test/output-schema.test.ts` (which parses each handler's
`data` — healthy **and** degraded — against its schema). The output schemas are **hand-mirrored**
from the `@claudinho/core` types and kept permissive (`.passthrough()` on nested objects); the
`.strict()` top-level guard test is the safety net that catches schema/handler drift, so keep it
green. `build:mcpb` injects these schemas into the Smithery-scored `.mcpb` manifest.
**Smithery (`.mcpb`) re-publish** — optional, and only when you want the Smithery listing to
track a new version: `pnpm -F @claudinho/mcp build:mcpb` (stages `mcpb/manifest.json` + the tsup
server + its external deps, smoke-tests it, then `mcpb pack` → `packages/mcp/dist/claudinho-<v>.mcpb`),
then `smithery mcp publish <that .mcpb> -n arturogarrido/claudinho`. The bundle pins the server at
that version (it runs the bundled code, not `npx`-latest), so the listing is a snapshot until you
re-publish.
**Lessons (learned the hard way):**
- npm deprecated classic "Automation" tokens — use **trusted publishing**, not a token.
- A brand-new package can't have a trusted publisher pre-configured; its **first** publish must be
a manual `pnpm -F @claudinho/<pkg> publish --access public` (enter OTP), then add its trusted
publisher for later releases.
- Account 2FA set to "Authorization and writes" forces an OTP that CI can't supply; trusted
publishing (OIDC) sidesteps it entirely.
## Commit attribution (all agents)
This repo is worked on by multiple AI coding agents. Commits produced with one are
**co-authored by the agent and the model** that wrote them, so `git log` (and the GitHub
contributors view) shows which agent — and which model — did the work. Add a trailer in the
**last paragraph** of the commit message, and credit the same in the PR body:
```
Co-Authored-By: <Agent> (<Model>) <agent-no-reply-email>
```
Use the model **actually in use**, not a hardcoded one. Examples, one per agent (Claude Code
uses Anthropic's no-reply address; Cursor and Codex follow the same pattern with their own):
```
Co-Authored-By: Claude Code (<actual Claude model>) <noreply@anthropic.com>
Co-Authored-By: Cursor (Composer 2.5) <...>
Co-Authored-By: Codex (<actual GPT model>) <noreply@openai.com>
```
Read the model from the current session; never infer it from an example, an older commit, or a
global default that the session may override. Use this `<Agent> (<Model>)` form even when the
coding client suggests a different trailer.
## Reviewing PRs (all agents)
- For PR reviews, first verify the local checkout matches the PR head before running gates:
`gh pr view <n> --json headRefOid,headRefName` and `git rev-parse HEAD`. If they differ,
check out or fast-forward the PR branch before reviewing.
- Review-only tasks are read-only unless the user explicitly asks for fixes. Lead with findings,
classify them P1/P2/P3, and include tight file/line references. If there are no findings, say
that plainly and list the checks run plus any residual risk. A note (a finding outside the
review's threat model, next bullet) is reported under Notes, never as a P1, P2 or P3. A note is
not pending: it does not withhold a confirmation.
- Do not treat a previous review, memory, or local branch name as current truth. Re-check the PR
SHA, merge state, and CI status at the end of the review.
- State the review's threat model in the PR description before the first round, and give it to every
reviewer. (This is the review's model, not the product's security model in `SECURITY.md`.) The default model: the programs a change runs beside (the coding client that loads the plugin
or the MCP server, the operating system, the user's shell) behave as they ship; they are not
adversaries. A finding that needs another program to refuse, hold or delay one of the change's calls to
its host, a reload or restart landing inside a window of milliseconds, or the system clock moving
backwards between two runs is a note, not a blocker, unless it is reproduced on the real host as it ships
(by a reviewer, the runner or the coordinator, who says how); then it is a finding. A finding a person reaches by using the product blocks as before: opening or closing a
window, a slow disk, a missing or old binary, a changed setting, the network. The model narrows what
blocks a review; it relaxes no rule in this file. The provider's data stays untrusted (the trust
boundary), our own processes racing each other stay in scope (the refresher's lock, the throttle note),
and a change may widen its model in its description. When fix rounds open a new case of the same kind
twice, stop and ask whether that kind is in scope before writing another fix.
## Codex / GPT specifics
- Claudinho exposes a model-independent MCP server; the shipped packages do not call an LLM API.
An agent-model upgrade concerns the consuming client and these instructions. The AI pundit and
gateway remain planned features, not migration prerequisites.
- When asked to recommend a GPT-6 model, evaluate `gpt-6-luna` for focused tasks where speed and
cost matter, `gpt-6.1-sol` for complex work that balances quality with time and cost, and
`gpt-6-astra` for the most demanding reasoning/review.
Use the [model-selection guide](https://developers.openai.com/api/docs/guides/model-selection)
as a starting point, then compare on representative project tasks at the same supported effort
before tuning effort. The API model pages for
[GPT-6.1 Sol](https://developers.openai.com/api/docs/models/gpt-6.1-sol) and
[GPT-6 Astra](https://developers.openai.com/api/docs/models/gpt-6-astra) list `low`, `medium`,
`high`, `xhigh`, and `max`; neither supports `none` or `minimal` in the API.
GPT-6 guidance: [model and prompting recommendations](https://developers.openai.com/api/docs/guides/latest-model)
and [maintaining agent instructions](https://developers.openai.com/blog/rethinking-skills-and-prompts-for-gpt-6-astra).
Keep repository-specific contracts and required gates; remove duplicated process instructions when
updating guides. These instructions also serve Claude Code and Cursor contributors.
## Conventions
- Shared domain types live in `@claudinho/core` — never duplicate them.
- 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: `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. (One stated exception, in standings: a payload that holds tables nobody inspected, more children than the bound or a child that carries a `children` value other than an empty list, is refused whole, because no table in it can be shown to be the only one with its key. That is not a record being refused; see the table-key bullet below.) 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.
Narration: guides/conventions/trust-boundary.md
- **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" only when the provider's INVENTORY of tables is complete; when a table it sent did not become one, an all-tables read returns the tables that were read and says `incomplete` (when none was read it is degraded: there is nothing to qualify), and a key that was not read is degraded, not absent. An all-groups read uses one fallback verdict because `StandingsResult` cannot honestly represent mixed live and static provenance.
- **A table has a key, and the key comes from the raw name.** `GroupStandings.group` is the key a surface selects by: a group letter (`A`), a numbered group (`A1`), a group under a league (`A-B`), or `LEAGUE` for a league's one table; every table but a lettered group also carries the provider's `label`. `parseEspnStandings` derives the key from the RAW name, whole, by an anchored grammar (the sanitized label is a different string: `Group A` plus an invisible character sanitizes to "Group A"). It works in two passes: names first (which children are tables, which keys are claimed twice: such a key belongs to neither), then rows, for at most `MAX_GROUPS` tables whose slot is taken before the first row is parsed. Nothing is skipped: a child either becomes a table or makes the inventory incomplete, and a payload with tables nobody can inspect (more than `MAX_TABLE_CHILDREN` children, or a child that carries a `children` value other than an empty list; a child with no such key is the normal case) is refused whole. A table at the payload's root, beside the children, is not read: it makes the inventory incomplete. A competition written down as having no table (`none`) answers with its own document, a name and a season (`sealSeason`: a year) and no table list or table, and with no root key but the measured ones (`uid`, `id`, `name`, `abbreviation`, `season`, `seasons`, and `children` when it is an empty list): only that is an empty answer. The keys are an allowlist, so an error body that also carries a name and a season is not that document. A league's single table is authorised per competition (`STANDINGS_SHAPE`), never inferred from a payload having one child. Two facts stay apart: `partial` (rows missing from a table) and the inventory (a table missing from the result). And a table key is not a fixture's group: only a lettered group feeds the group map.
- **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.
Narration: guides/conventions/trust-boundary.md
- **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.
- **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`.
- **A verdict becomes output in ONE place.** When a result says something about itself that changes what a reader may conclude ("not available for this competition" is not "none found"), it states it as a field, and every surface turns that field into a structured key and a sentence through core (`packages/core/src/verdict.ts`): `verdictExtras` for the key, `verdictNotice` for the sentence that REPLACES the body, `verdictQualifiers` for the sentences that QUALIFY it. A surface never forwards the field by hand at an emit site: Adding a verdict means adding it there and declaring its key in the MCP output schemas (`verdictOut`; `incompleteOut` for the standings tools; `partialOut` for `get_today`, `get_live`, `get_match`, `get_next_fixture`, `get_bracket`, `get_market_signal` and `get_share_snippet`; in `packages/mcp/src/server.ts`; the strict schema test refuses an undeclared top-level key). A verdict that replaces the body (`verdictNotice`: six, the first stated in this order is said: `noCompetition`, nothing is chosen, stated by a selection (`selectionVerdict`), never by a read, and declared on every competition-answering MCP output schema, the standings tool's included; `unsupported`, not offered for this competition yet; `inapplicable`, the competition has no such thing, a bracket; `unknownTeam`, the roster the competition has holds no team by that name, with `rosterEvidence` saying which and the sentence naming it, never the competition: `bundle`, the World Cup's nations; `table`, the table read whole and a WHOLE read of the next 14 days' fixtures (a club out in a qualifying round is in no table, and an incomplete read is the partial empty body, never this claim); `rosterIncomplete`, the roster could not be read whole and the name could not be answered without it, which is not an outage; `betweenEditions: { ended, label? }`, the read's edition ended and nothing in it is current, `ended` being the provider's calendar day of the season end it STATES) is printed instead of it; one that qualifies what is shown (`verdictQualifiers`, in a fixed order: `incomplete`, tables are missing; `partial`, the read the result was made from was not whole (a knockout window, a dated or live window, discovery, a match's refresh), `partial: { omitted }` with the count of provider records left out when it is known, `partial: {}` when not) is printed with it, on a populated body and an empty one (an empty `next` beside `partial` is "none read", never "eliminated"), and FIRST where a text is cut at a length (a tool's answer, a share card), as a table's `partial` sentence is printed before its table (the standings sites keep the positions they had: CLI `table` after the tables, MCP before). When a result states a replacement, its qualifiers are not printed. On a surface a cut can reach (a tool's text, a share card) every sentence that qualifies the body is printed BEFORE it, and nothing follows the body but the footer: the verdict's qualifiers first, then the sentence counting the shown rows a read did not serve, then the surface's own notes (a degraded line, a market-incomplete notice, a list's truncation, a roster note, a bracket's note) in the order they used to follow the body (MCP `qualified(body, result, lang, ...also)` takes the sentences after the verdict's as a list; the standings tool and the share formatters keep the same order themselves). The CLI commands other than `share` are not cut and keep their notes after the body. A result states `partial` only when its read SAID it was not whole (`fetchMeta`: `complete: false`), never when the adapter said nothing, and a read that states it succeeded, so it is not what makes a result `degraded`; one result carries both: an off-bundle `match` whose refresh was not whole and did not hold the id is `partial` (the refresh was) AND `degraded` (the record shown is the provider's earlier one); the count is believed only as a positive integer, and the one rule that turns a read's account into the verdict is `partialOfRead`; the cut drops the end of the body, never the tool's footer (the attribution and the disclaimer: `ToolResult.footer`, kept by `toContent`), except that a DATE's list (`get_today`, the date card) on a read that was not whole keeps only the disclaimer (`ToolResult.cutFooter`): the rows a cut drops may be every row the provider served, so the attribution line goes with them while the structured `source` keeps the provider; a live list keeps its line after any cut (every row it shows was served); every emit site of a result that can state one already passes that result through, and a result type that gains its first verdict is wired once, where it is emitted. A table's structured form, with its `partial` verdict, has one mapping too (`tableData` in `packages/core/src/standings.ts`). The share cards that `claudinho share` and `get_share_snippet` hand out are assembled once, in `packages/core/src/share/cards.ts` (title, empty note, run cue, attribution, verdict, and `note`: the qualifying sentences, which the share formatters print before the body whenever it is set, followed by the card's own notes, also before the body); a surface serializes the card and, for MCP, bounds its list. `packages/core/test/verdict.test.ts` guards this by vocabulary (a surface's code never spells `unsupported`, `noCompetition:`, `incomplete:`, `partial:`, `inapplicable:`, `unknownTeam:`, `rosterIncomplete:` or `betweenEditions:` outside the MCP schema, a verdict's sentence or i18n key, the horizon, window, "none read" or unserved sentence, a card's empty note or run cue, or a table's `partial` key); it catches a copy, not every way to write one, so the rule is still yours to keep. The empty-body sentences off the bundle are NOT verdicts: `next`'s horizon (`horizon: { days }`, "no fixture for X within the next 14 days") and `match <id>`'s window (`window: { from, to }`, "not found between A and B") after a whole read, and "none read" after one that was not whole ("no fixture for X was read in this span", "no match with id N was read in this span", under the `partial` sentence) are the answer's own text, built once (`nextHorizonSentence`, `matchWindowSentence`, `nextNoneReadSentence`, `matchNoneReadSentence` in `share/cards.ts`), with the span as a plain field; a verdict replaces them, and they never suppress a qualifier. The same holds on the bundle and for the live and dated reads: an empty body after a read that was not whole says nothing was READ, never that nothing exists (`live`: "no match in play was read"; a day that merged no bundled schedule, off the bundle or the bundle's slug answering for another edition: "no fixture was read for D"; a dated market answer: "no market signal among the fixtures read for D", which is market copy from the approved bank, English on every locale; `liveNoneRead`, `dateNoneRead`, `marketsNoneRead`, each built on its `...Sentence`). A day whose read merged the bundled schedule stays whole and keeps the bundle's empty answer. A day whose read FAILED and merged none (off the bundle) says the provider could not be reached ("couldn't reach the data provider — no fixtures confirmed for D", `dateUnreached`, the date card's sentence on every date surface), and its degraded line says the same, never "showing the bundled schedule", which only the bundle's degraded day says. The dated read says what it is made of in two request-local fields, never cached: `skeleton` (the bundled schedule was merged under the records) and `served` (the ids the window held); the bundled `match <id>` read states `served` too. `served` rides in the structured twins as a plain field beside `source`, not a verdict, only BESIDE `partial`, the verdict it interprets, and bounded like the rows it interprets: the SHOWN fixtures whose record the window held (the day's rows, MCP's bounded list, the card's matches, the one match), never the window's every id. One rule (`servedExtras(result, shown)`, beside `verdictExtras`) at every site that carries it (`today --json`, `share <date>`/`<id> --json`, `match --json`, MCP `get_today`, `get_match` and `get_share_snippet` `data`), so a whole read's structured output keeps its shape; the results themselves state the window's ids on every successful read. On a read that was NOT whole a day's surface decides its attribution from them over the fixtures it finally DISPLAYS (after the date filter and, on MCP, the bound), through ONE rule (`dayAttribution`): displayed rows the window did not serve are counted in a second qualifying sentence ("N fixture(s) shown from the bundled schedule; their live state is unconfirmed", after the `partial` one), and when NONE of the displayed fixtures was served the "Live data" line is not printed (CLI text, the MCP text's footer, the date card, whose `source` then is null) while CLI `--json` `source` and MCP `data.source` keep the overlay's provider. The bundled `match <id>` (CLI, MCP, its card) asks the same rule of the one record it shows: a record its window did not hold is named as the bundle's. A whole read changes nothing; `live` never shows a row its read did not serve. The market surfaces print the fixture read's qualifiers beside the market body and carry its key, apart from `complete`/`marketComplete`, which describe the market requests; `marketFixtureForTeam` states the verdict of BOTH its reads (the knockout window, and the candidate's own day when it refreshed one), the counts summed when every stating read knew its count, over the responses like the window's own count (a record both refused counts in each): an upper bound on the records left out; the dated market answer forwards its dated read's verdict where markets are read.
Narration: guides/conventions/verdicts.md
- **Off the bundled competition, `next`, `match <id>` and `bracket` answer from the competition itself.** `next <club>` resolves the query (a name or a code, as typed) with `resolveClub` against `rosterFor` (the standings, one read, `complete` only when a table was asked for and read whole with an id on every row) and the schedule ahead as positive evidence: one hit with an id resolves (one hit by CODE or by a FUZZY name only against a roster read whole, or where no table exists: a code is not an identity and a partial name may be another club's too; an exact name resolves anyway), two or more are `candidates` (never a pick), none is `unknownTeam` only against a complete roster and a whole read of the span; where a table was asked for and not read whole, a name that cannot be answered without it is `rosterIncomplete` (a verdict, not `degraded`: the provider answered). The fixture is the club's earliest not finished, cancelled or postponed (in play included) in discovery's span (`scheduleSpan`: yesterday to 14 provider days ahead), selected by `isTeam` (when both carry an id, equal ids decide: a side carrying another club's id is never this club's fixture, whatever its labels; otherwise the same code and name), never by `sameTeam`, which stays the pairing refusal; a whole read with none answers `horizon: { days: 14 }`, one that was not whole `partial`, no horizon and the "none read" sentence. `match <id>` looks in the same span (`window: { from, to }` when a whole read did not hold it), then refreshes the record from its own day asked across seasons (a refresh that failed or did not hold it answers the month's record, attributed and `degraded`). `bracket` is a capability with three values (`bracketCapability`): offered on the bundle, `inapplicable` for `NO_BRACKET` (the table's `bracket: 'not-applicable'` rows: league seasons with no knockout tie of their own; a competition joins by a written fact, never by its standings shape), `unsupported` for the rest. A read whose season ended (`endDate`, agreed by every stating part) on a provider day strictly before the day asked, read whole, with nothing scheduled or in play in it, states `betweenEditions` instead of its body (the dated read, the live read, `next`, `match` for an id the read did not hold); never on the bundle. The end is the provider's STATED season end (`ended`: its calendar day), an administrative date, not the last match: A match found by its id is answered as asked, the previous final included (a historical question, like `today` on a date of the old edition). For the dated read the body is the asked LOCAL date (`getMatchesForDate(adapter, date, tz)`, the viewer's zone, the one the surface files the day by): any record on that date, a finished result included, blocks the verdict, and the window's records are returned in every case (a UTC viewer asking the final's UTC date sees the final; the day after, the sentence).
Narration: guides/conventions/off-bundle-reads.md
- **A window is composed of the requests the provider accepts.** `EspnAdapter.fetchWindow` asks a day at a time (up to three days) or a month at a time (longer; more than three months is refused unasked), sends the parts together, and settles every one before it answers, so a throttle that arrives after a sibling's quick failure has armed the cooldown. It returns ONE result with one account: any part's failure fails the window (a throttle wins: the one the adapter retains, whose deadline is latest counted from when each was received); a part whose ENVELOPE cannot be read is a failed part, whatever its siblings hold (the unreadable-envelope rule above is asked of each response); a part that filled the request's `limit` (the provider returns a silent chronological prefix) fails it too; a refused RECORD, or a fixture in two parts (compared before a month is narrowed to the window; the first copy counts), leaves it `complete: false` with the readable rest usable, and the "no readable record" rule is asked of the whole window, not of each part. Its account states `seasons` (every distinct season the parts stated, in the order asked; empty is "none stated") and `omitted` (the records that made it not whole: the parser's count per part, a refused record, a duplicate, one beyond the bound, plus each second copy across parts; 0 exactly when `complete`; absent on a single read that filled its limit, whose tail nobody can count). The count is over the RESPONSES the window was composed from, a month response whole: a record refused outside the span counts (it already makes `complete` false, and a refused record often has no readable date), so it bounds what the span may be missing, and every sentence built on it says "may be incomplete". Two season rules, kept two: the WINDOW states the season its stating parts agree on (a part that states none does not veto); DISCOVERY states one only when every month states the same. Two seasons in a window are then a failure when it is asked strictly (the default, and every caller that merges the bundle or keeps a slice: an absent season would let the bundle apply or a slice of another edition stand), and a composed answer with no `season` and both in `seasons` when it is asked `acrossSeasons`, which a caller that merges nothing and keeps no slice does (a refusal there was three requests spent on a verdict nobody can act on): the live read (`getLiveRead`: it keeps the matches in play), and OFF the bundled competition the dated read (`getMatchesForDate`: no skeleton to merge) and the match read (`match <id>` refreshing the one record it found). On the bundle the dated read on a turn day is still refused. A composed season keeps its start and end date only when every stating part (every month, for discovery) stated the same one (`agreedSeason`): agreeing on the year is not agreeing on when the edition ends. A fixture's day is the PROVIDER's (US/Eastern, measured; `bucketDay`), which is what the month filter keeps by and what the canary checks on every dated response. An incomplete answer cannot prove a fixture is gone: a caller that keeps a previous answer (the refresher's knockout slice, and off the bundle its schedule slice) must not let it erase one. It keeps what the answer did not READ (`mentioned`: everything the answer read, also a tie it set aside as postponed or played). The knockout slice does so only while both seasons are known to be the same; with either unknown the slice stands as it was and the read counts as a failed attempt. The schedule slice does so unless the two seasons are known to DIFFER, because there an unknown season is an ordinary state (a discovery whose span touches two seasons states none); only an answer known to be another season replaces it (`applyDiscovery`). The season it then stores is a claim about every entry, so a union that KEPT entries stores the answer's season only when the two were already known to be the same, and none otherwise: a kept entry is never given a season it was not read under (an answer of "another" season could then delete it). The knockout-facing interactive reads (`next`, `bracket`, their share cards; CLI text and `--json`, MCP text and `data`) carry the window's `complete: false` as the `partial` verdict (see the verdict bullet), and so do `next` and `match <id>` off the bundle (from discovery, or from the match's refresh), and every other interactive read: `today`, `live`, `match` on the bundle, the market surfaces (`markets <id>`, `markets <team>` through `marketFixtureForTeam`, `markets <date>`), and their MCP and share twins (see the verdict bullet for what each prints). The statusline, the hook and the cache do not carry it: they are ambient and cache-only; the cache's live slice carries ONE boolean about its read, `liveComplete` (whether the read that filled it was whole), which the statusline's syncing rule and `ambient --json`'s `current` ask, and no other verdict. The snapshot also keeps the refresher's record of play (`play`, `packages/cli/src/play.ts`: the ids it saw in play, and the matches a WHOLE read then returned finished, each with the time first seen so, for 10 minutes), advanced by one pure function (`advancePlay`) under the lock on both publishers, kept as it was across a failed, degraded or partial read, carried on every publish, and never decided by a truncated list: full time is a match seen in play that a whole read returns finished, never a match that left the list; `ambient --json` lists it as `finished`, each entry for its own 10 minutes by the reader's clock while the snapshot's last live read was whole (`liveComplete`), whatever the snapshot's display window; the statusline's countdown and `ambient --json`'s `next` read only its ids (a match the record holds, in play or finished in its last 10 minutes, is never a due fixture's `now`; `playRecordIds`, once per render, without sealing a record), the hook reads none of it, and the hook's line gains `(FT)` for a finished record only (an English token, as the ambient surfaces are). A three-day window cut at the provider's limit still THROWS (it is `degraded`, not `partial`): turning it into a returned partial would change what the refresher does with it.
Narration: guides/conventions/off-bundle-reads.md
- **A refresher decides under the lock, and a throttle always has somewhere to be written.** `runRefresh` takes a first look at the cache without the lock, and that look may only decide NOT to ask; a decision to ask the provider is made from the state read after `claimLock` (between the two, another refresher can have published a fresh slice or a backoff). A provider throttle (403/429) goes into the snapshot under that lock, and ALWAYS into the scope's NOTE (`backoffNotePath`: lock-free, atomic, never deleted, an expired one is just not believed): the lock can be taken, the publish refused or its write failed (a publish that throws is one that did not happen), the snapshot unreadable, and a reader of another cache format rejects the snapshot whole, deadline included; it used to be dropped. A throttle counts as written only when the note holds it: after its attempt to publish, refused, failed or not, each writer (a command's `persistBackoff`, the refresher) asks `ensureBackoffVisible`, which writes the note unless the note already holds a deadline at least as late (`writeBackoffNote` reads the note back): one small write per throttle, never per cycle. Across a cache format bump the snapshot's deadline is read whatever the file's version (`backoffInEffect` with no state read), and a writer replacing a snapshot of another version notes that snapshot's believed deadline first (`writeState`). The backoff in effect is the later BELIEVED deadline of the snapshot's and the note's: every reader asks `backoffInEffect`, at the time it is then: the hot-path triggers (LAST, so the note is looked at only once a refresh would otherwise be started), the refresher when it decides, again before its second lane, and at publish, and a command arming its adapter. Every writer keeps the later believed one, comparing with `backoffInEffect` (the note included), never with the snapshot alone. `believedDeadline` is the one rule for a deadline: in the future and at most 30 minutes ahead; a value that is not believed neither beats nor hides a real one. A cycle whose base read is undefined (no snapshot of this scope and format could be read: none, unreadable, rejected) is paced by the scope's ATTEMPT RECORD (`attemptRecordPath`, beside the note, `{ at, count }`, never deleted, read only when no snapshot could be read), on every lane, the idle writers and the unknown-source publish included: under the lock and before anything else, a believed record that is not due returns (nothing written, nothing looked at), else the attempt is RECORDED (`admitAttempt`: `count + 1` written and read back) and the cycle goes on with no look; only an attempt that could not be recorded asks the look, last, and the gate fails closed only where the look SEES that a publish could not heal (`admitNoBaseCycle`): a publish is an atomic write whose temporary file is made in the cache directory and renamed over whatever is at the snapshot's path, and the replacement is ours, keeps the mode bits of whatever it replaces but a link and nothing else of the entry (no access-control entry of its own, no other owner), and gets what the directory gives a new file; the look answers from the entry's kind, its own bits and a probe, and names two states a publish cannot heal: a directory (the rename fails) and an entry that is not a link whose own mode denies its owner a read (the owner-read bit clear, whatever the kind: a regular file, refused or opened through its other bits or an access-control entry of its own; a pipe; a socket), unless a file made there with those bits still reads back: the directory's inherited permissions (`snapshotUnhealable`, through core's `lookAtEntry`: `lstat`, never following a link; where the owner-read bit of an entry that is neither a link nor a directory is clear, before any open, a probe file made beside the path with those bits, opened and removed, so the answer is measured, not predicted; an open only for a regular file). There a record that is not visible means the lock released, nothing published, nothing asked; in every other state (no entry; a symbolic link to anything, of which nothing is kept; and any other entry whose owner-read bit is set, whose replacement is ours and reads back: a pipe or a socket; a cache file refused, by an access-control list or another owner's 0600; a cache file that opened and was rejected: bad JSON, another format version, over the reader's bound, another scope's; and an entry whose bit is clear where the probe reads back) the cycle goes on whether or not the attempt could be recorded, and its publish heals in one cycle where the rename lands. An entry the system refuses to replace for a reason the look cannot see (an immutable or append-only flag: `lstat` reports no flag) is answered as it would be without the flag: where the look finds it healable (its owner-read bit set, or a file made there with its bits reads back) it is not gated: its cycle proceeds, its publish is one that did not happen, and the record left as its admission paces it where the record works; where it does not, it behaves as before this record existed. (`lookAtSmallFile` cannot ask it: it answers `unreadable` alike for a file it cannot open, one over its bound, and a link to nothing.) After the cycle, only while the lock is still held, a snapshot that reads back usable resets the record to `count: 0` (`settleAttempt`); the pace is one minute doubling to thirty (`attemptDelayMs`), with no age bound (the count is carried); the hot path asks it after the lock and before the note, on its no-snapshot branch alone. The limit as it is: where a publish could not heal the snapshot's path, a record nobody can read admits nothing (the provider is not asked, and the spawn per tick stays) until a publish could heal it (the entry removed, its owner-read bit set, or the directory's inherited permissions letting a new file with those bits be read) or the record works again, never because the snapshot file can be opened (a mode-000 file with its own allow-read entry opens, and its replacement would not); in every other state the cycle proceeds as it did before the record existed, and its publish ends the loop where the rename lands (not under an immutable or append-only flag, which the look cannot see: a file under one that this reader rejected is answered as it would be without the flag, paced with a working record and as before without one where the look finds it healable, gated where it does not; one that reads never reaches the record, so the provider is asked whenever a read is due and the snapshot is never replaced, as before). The lock API has one form: `holdsLock`, `releaseLock` and `publishState` take the token `claimLock` returned. A claim the claimer cannot read back is no claim (`claimLock` reads its token back; a lock nobody can read, in a cache directory whose new files nobody can read, admits no cycle: nothing asked, nothing published; the spawn per tick stays). Every file the CLI keeps (the snapshot, the note, the attempt record, the lock, the market cache, the run counter) is read through one descriptor, bounded and without waiting (`lookAtSmallFile`/`readSmallFile`, core's `files.ts`, re-exported by the CLI's `paths.ts`), and the user's config file through its no-follow form (`lookAtOwnFile`, under `readUserConfig`: `O_NOFOLLOW` at the open where the platform has it; on Windows, which has none, the entry is looked at, then opened, and the two must be one file, the same device and inode, else it is `symlink`), by the CLI and the MCP server alike; the only reads by path are the editors' settings file and standard input (the CLI), and none at all (the MCP server), pinned by each package's `cache-reads.test.ts`. A lock that vanished while a claimer looked at it is not removed (the claimer creates it once more); a stale one is taken over. Not closed, and stated in `claimLock`: two stealers of the same stale lock, and a lease lost while its owner still runs (the ownership check before a publish is not fencing).
- **Off the bundled competition the refresher discovers, then polls only when a match can be in play.** There a cycle is now: DISCOVERY if due (`getScheduleAhead`: yesterday to 14 days ahead in provider days, each calendar month its own window, so two months that state two seasons are two answers, and the discovery states a season only when every month states the same one; the "no readable record" rule is asked of the whole discovery, not of each month: a month whose list held only records nobody could read read nothing, which leaves the answer incomplete while another month read something, and fails it when none did; once an hour, 5 minutes doubling to an hour while it fails, anchored on an attempt that is written BEFORE its request); the backoff again; the GATE on the slice as it now is; a live read if the gate is open and the live slice is at least 12 seconds old; then one final publish. A throttle is never a failed discovery, however short its wait, and every throttle gets the same five-minute floor before the provider is asked again, on both paths (`backoffToPublish`). The gate is open only for a discovered fixture that is on (not postponed, cancelled or finished) inside its 140 minutes, a match SEEN in play that no whole read has yet seen to be over (at most six hours past its kickoff), or one probe owed after a failed discovery. Every rule about the cache's `schedule` slice is a pure function in `packages/cli/src/scheduleSlice.ts`, and what is read back from the file is believed only within bounds (`scheduleView` is the one reader of everything in the slice but its display records: the index, both stamps, `failures`, `complete`, `inPlayUntil`, `probe`, the season; an index is whole or absent, never a prefix, an entry further ahead than discovery reads is not believed (`SCHEDULE_HORIZON_MS`), and its entries have one constructor in core's trust module; the display records are sealed where they are used, like every cached match). `refreshWanted` is the ONE trigger for the statusline and the hook: never during a backoff, with a snapshot or without one, and never for a source nobody can ask once its idle snapshot exists. The statusline reads the slice's display records for a countdown and its gate (never the probe) for `live · syncing…`, and names no record that is postponed, cancelled or finished (`hasLiveWindow`, the index's own rule); `⚽ —` is the line for "nothing known". Detection is best effort and says so: it needs an invocation, a usable answer and no backoff. On the bundled competition nothing changes (after the one cycle that rebuilds a cache written in the older format), and the knockout `fixtures` slice stays the bundle's.
Narration: guides/conventions/refresher.md
- **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.
- Static data (schedule, groups, flags) ships bundled in clients; only **live state** hits the network (and, for a competition the bundle does not describe, its schedule ahead: there is no static one to ship).
- **Standings come from the provider's standings feed, NOT computed from a match window.** `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.
Narration: guides/conventions/standings.md
- **`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). Since 0.11 it is the OVERRIDE seam: a user follows a competition with `claudinho follow <alias>` (the saved choice), and the environment wins over that choice while it is set (a server's `CLAUDINHO_COMPETITION`, a shell that exports one, the refresher the statusline spawns with the slug it resolved).
- **The supported set is ONE table, and every written fact of a competition derives from it.** `SUPPORTED` (`packages/core/src/supported.ts`, a leaf module) holds the sixteen competitions, one row each: slug, alias, name, the teams' kind, the competition's kind, a league's season name, the standings shape, the `bracket` and `markets` capabilities (`offered`, `not-offered-yet`, `not-applicable`) and the edition cadence. `deriveTables(table)` builds every other view (`TEAM_KIND`, `COMPETITION_KIND`, `SEASON_SLUG`, `STANDINGS_SHAPE` in `kinds.ts`, which adds the EXPERIMENTAL extras, `fifa.friendly`, overriding the table for their slug; `NO_BRACKET`; `MARKET_COMPETITIONS`; every view frozen; a row offering a bracket for any competition but the bundled one, `BUNDLED_SLUG`, is refused there, at load), and every consumer takes the table as its input (`entryOf`, `capabilitiesOf`: scores and next offered everywhere, standings unless the shape is `none`; `bracketCapability`, the row's bracket as `offered`, `inapplicable` or `unsupported`; `competitionLabel`; `listCompetitions`; the canary's competitions and cadences, read from the table core hands it; the README matrix, generated). Adding a competition is, in this order: the row; the written facts beside it in the tests that spell a fact per row (`supported.test.ts`: its slug, alias and name tables and its derived-shape expectation; `competition-kind.test.ts`: its kind lists; `standings-tables.test.ts`: its shape object and, for a league, its recorded standings payload under `packages/core/test/fixtures/standings/<slug>.json` with a row in the per-league case; `espn-canary.test.ts`: its watched-slug and league-shape lists); the counted copies the public-copy guards name (`packages/core/test/public-copy.test.ts`: the framing's "and N more", the "N competitions" counts; `root-package-copy.test.ts`: the root `package.json`; `disclaimer.test.ts`: the Registry card's verbatim sentence in `packages/mcp/server.json`); its alias in the five keyword lists (`packages/{cli,mcp,core}/package.json`, `packages/mcp/mcpb/manifest.json`, `.cursor-plugin/plugin.json`); then, after a build, `pnpm gen:readme-matrix`. Every other test that counts the table reads `SUPPORTED.length`, so nothing else changes (`supported.test.ts` runs every derivation on a table with a row past its end). Every lookup is by own property or by a row's slug.
- **The competition is decided ONCE, at the edge, and then travels as a value.** `resolveCompetition(explicit, env, saved)` is called in exactly two places, and core reads no environment and no file: the CLI's option resolution (`packages/cli/src/config.ts`: `--competition`, `CLAUDINHO_COMPETITION` and the saved choice → `cfg.selection`, and `cfg.competition`, its slug, `''` when refused or nothing is chosen) and the MCP server's request edge (`choiceOf`/`selectionOf` in `packages/mcp/src/tools.ts`: the tool's `competition` argument, the server's `CLAUDINHO_COMPETITION` and the saved choice, once per request, before `resolveAdapter` builds or reuses an adapter, which stays keyed by source and competition, with one throttle window per source shared by every adapter of it still alive, kept or evicted while a request holds it (weak refs, never what keeps one alive; one non-reentrant broadcast per throttle), and a bound on the kept adapters, `KEPT_ADAPTERS_MAX`, least recently used evicted). The SAVED CHOICE is the user's config file (`config.json`: `{ version: 1, competition, team? }`, at `configPath(env, platform, home)`: `$XDG_CONFIG_HOME/claudinho/`, else `%APPDATA%\claudinho\` on Windows, else `~/.config/claudinho/`), written only by `claudinho follow` (atomic, 0600 enforced, never through a link) and read at each edge, once per invocation or request, through core's one no-follow bounded reader (`readUserConfig`): a file that is absent, a symlink, unreadable, malformed, of another version or naming no competition the resolver's grammar accepts is NO saved choice (never a guess), with the reason kept for `follow` to print; a pin that is not exactly `{ id?, code, name }` (the team-id grammar, human labels) is dropped and the competition kept. The FIRST present source decides (flag, then environment, then the saved choice); an empty string is absent; with none, `{ kind: 'none' }`: nothing is chosen, and the finished World Cup is not a default anyone falls into (`DEFAULT_COMPETITION`/`BUNDLE_COMPETITION` name the bundle's slug for `bundleApplies`, never a selection). With none, nothing below the edge runs: every competition-answering CLI command throws ONE input error (`selectionRefusal`: "No competition chosen. Pick one: `claudinho follow premier-league` …", exit 1; on `--json` the object `{ competition: null, noCompetition: true }` on stdout first), with no request and no cache read; `team`, `follow`, `init` (which then says how to choose), `star` and `--version` need none; the statusline prints `⚽ claudinho follow` with no cache read and no refresher, the hook prints nothing, `vibe` its line with no segment, `_refresh` returns; every competition-answering MCP tool answers the `noCompetition` verdict (`selectionVerdict`, the first replacing verdict) before any adapter is built: its text the one sentence, its data the tool's own empty healthy shape (a `refusal` builder beside each answer, `said`) plus `competition: null` and `noCompetition: true`; `list_competitions` answers `current: null`, `standings://` the sentence, `get_team` as ever. Each value is an alias (`premier-league`), a slug in the table (`eng.1`), or a raw ESPN slug the table does not hold (`^[a-z0-9_]+(\.[a-z0-9_]+)+$`, 64 units at most: real slugs carry underscores, `esp.copa_del_rey`; `experimental`, named by its slug; one grammar, `competitionValue`, asked by the resolver and the config reader alike); anything else is REFUSED with the aliases (`selectionRefusal`), never a request, even when a lower source is valid: every competition-reading CLI command (`init` and `star` read none) throws an input error before any request, an MCP tool a tool error before any adapter is built; the ambient commands contain it (`prompt` prints `⚽ —`, the hook nothing, `vibe` its line without a segment; no cache read, no refresher). `selectedCompetition` is the one constructor of a selected value. **The pin** (`follow <alias> --team <name>`, resolved exactly as `next <name>` resolves it: the bundle's nations offline by code, `{ code, name }`; a club through `next`'s own reads, `{ id, code, name }`; ambiguous or unknown writes nothing) belongs to its competition, not to the source that chose it: it applies whenever the selected competition IS the file's (an alias and its slug are one), whether the file, the flag or the environment chose it, and never under another competition (core `pinUnder`, asked by `cfg.pin` and MCP `choiceOf(...).pin`); a `follow` of another competition without `--team` drops it, and `follow --team` cannot pin while its competition is between editions (`next` answers the verdict before it resolves a club). The team-taking commands take, in this order, the argument, `CLAUDINHO_TEAM`, the pin (CLI `teamAsked`; MCP `get_next_fixture` the same, its `nextAsked`, the server's `CLAUDINHO_TEAM` read once at the request's edge, a tool error when there is none of the three); the pin answers as a RESOLVED team through `nextFixtureForPin` (built by `sealTeam` with the competition's written kind; its fixture by `isTeam`, the id deciding; by code for an id-less pin, which the reader believes on the bundled competition alone, its nations carrying no id; no roster read). A pin's labels are bounded as a team's (`TEAM_CODE_COLUMNS`, `MAX_LABEL_COLUMNS`: the writer copies a resolved team), its code upper-cased as a feed's is. A relative `XDG_*` value is invalid and ignored by both path rules (the base-directory specification). The ambient surfaces prefer, never filter: ONE function, `pickAmbientMatch(matches, pick)` (the statusline's live line, syncing matchup and countdown, the hook's list, `vibe`'s segment), puts the picked team's matches first and keeps the others (the statusline shows the picked live match and counts the rest, `+N`), the pick being `CLAUDINHO_TEAM`'s code, else the pin when `CLAUDINHO_TEAM` is absent (one the hot path cannot read, a name off the bundle, is still the override: no preference, never the pin). An adapter injected through a test seam must serve the selection's competition: one for another is refused before any read, as an input error (CLI `adapterFor`) or a tool error (MCP `resolveAdapter`), so a body is never one competition's under another's name. Below the edge the adapter STATES the competition it serves (`ProviderAdapter.competition`, required) and every function asks the adapter or the config — never the environment, never the config file, and never a default argument (the cache's path functions take a competition always). `packages/core/test/selection-identity.test.ts` fails on a call anywhere else, on one with no argument, or on an edge that does not hand in its flag, the environment and the saved choice.
Narration: guides/conventions/selection.md
- **Every answer names its competition, once, in one place each.** The text of every competition-answering answer (CLI `today`, `live`, `next`, `match`, `table`, `bracket`, `markets`; MCP every tool but `get_team`, `get_share_snippet` and `list_competitions`; the `standings://` resource) prints the MODE LINE (core `modeLine`): the competition's name, then ` · from the command line` (MCP: ` · from the request`) or ` · from the environment` when the flag or the environment chose it, then ` · experimental` for a raw slug; localized but for the name; dimmed, right after the CLI header (`next` prints its header whether or not it found a fixture; first where an answer has none: an empty `match` or `markets`, or a verdict that stands instead of a `bracket` or a market answer; `table` prints it once, before its tables), first in an MCP tool's text and in the `standings://` resource's (`fixtures://` names the World Cup first: it is the bundled schedule, whatever the selection). Never on `--json` (`match --json`'s `lines` excepted: they are the text's own rows, the mode line among them), never on `share` or `get_share_snippet` (the card is the artifact: its title names the competition). The structured twin is ONE key, `competition: { slug, alias?, name, chosenBy, experimental? }`, built by core's `selectionExtras` and spread at every emit site (CLI `--json` of those commands and of `share`; MCP `data`, declared as `selectionOut` on the eight output schemas). `team` / `get_team` read the World Cup's roster whatever the selection, name that roster, and carry no key; CLI `team` is refused under an explicit selection (the flag or the environment) of another competition. Every share card's title names its competition and its run cue selects it, the World Cup's too (`npx @claudinho/cli --competition <alias, or a raw slug as typed> ...`).
- **The stage comes from a written grammar over the WHOLE season slug, with the competition's written kind.** An event's `season.slug` is the only phase it carries. `stageFromSlug` (`core/src/trust/espn.ts`) bounds the raw value (a string of at most 64 units matching `^[a-z0-9-]+$`; anything else, an absent, `null` or empty slug, or a property every object has, is `OTHER` with no words: an unstated phase asserts nothing, and is not `GROUP`, the one stage `sealMatch` refuses a shootout on), then reads, in order: the exact slugs under any kind (`group-stage`, `round-of-32`, `round-of-16`, `quarterfinals`, `semifinals`, `3rd-place-match`, `final`, `league-phase` → `LEAGUE`, `knockout-round-playoffs` and `relegation-playoffs` → `PO`; `friendly` → `FRIENDLY` under the friendly competition only); under a `league` only, `regular-season`, `torneo-apertura`, `torneo-clausura` and the season's written NAME after a year (`SEASON_SLUG`: `2026-27-english-premier-league`, `-laliga`, `-italian-serie-a`, `-german-bundesliga`, matched whole) → `REGULAR`, and `mex.1`'s `(apertura|clausura)---(quarterfinals|semifinals|finals)` → `QF`/`SF`/`F`; anything else is `OTHER` with the provider's own words (`Match.stageLabel`, the slug humanized, a 40-column human label, kept only on `OTHER` on both paths, printed untranslated). No substring rule, no wildcard. `COMPETITION_KIND` (`league`: `eng.1`, `esp.1`, `ita.1`, `ger.1`, `mex.1`, `uru.1`; `friendly`: `fifa.friendly`; `cup`: the other ten and any unlisted one) and `TEAM_KIND` are written facts, derived from the supported table like `NO_BRACKET` (the friendly competition is an experimental extra beside it). Measured on the real feed (Oct 3, 2026): the season slugs above, `torneo-apertura` and `clausura---quarterfinals|semifinals|finals` (`mex.1`), `league-phase` (`uefa.champions`, `uefa.nations`), `knockout-round-playoffs` (`uefa.champions`), `relegation-playoffs` (`uefa.nations`), and the tournament rounds. UNMEASURED, so `OTHER` with their words until written down: `ita.1`'s and `ger.1`'s play-offs, and `fifa.cwc` (no event served). `isKnockoutStage` (`R32`…`F`, `PO`) is the one "is this a knockout tie" predicate, asked at the nine sites that used to test `GROUP`/`FRIENDLY`: `sanitizeBundledFixture` and `liveWindowMsFor` (core `schedule.ts`), `knockoutWindow`, `getBracket` and `getKnockoutFixtures` (`live.ts`), `buildBracketTopology` (`bracket/build.ts`), `buildBracketView` (`bracket/resolve.ts`), the refresher's `inKnockoutPhase` (cli `refresh.ts`) and `gen:schedule`'s knockout-leak check (`core/scripts/build-schedule.ts`); on the bundle, whose stages are GROUP and the rounds, each answers as before. `canGoToPenalties` is not it (`stage !== 'GROUP'`). Where a stage is joined into a line with ` · `, the segments go through one joiner that drops empty ones (`joinSegments`): an `OTHER` with no words, or a record with no venue, prints no segment and no separator.
- **A team's identity is the provider's id; its code is a label.** `Team.id` is the provider's stable id, namespaced (`espn:359`), the same for a club in every competition, present on a team read from a feed (and read back from the cache) and absent on the bundled skeleton. `Team.code` is a display label: it is never matched against a shape, and it does not identify a team (a real club abbreviates to `O&M`, and `CAR` is two clubs in one competition). The places that still SELECT a team by its letters (`fixturesByTeam`, the ambient pick's `CLAUDINHO_TEAM` code, the team argument) are World Cup behaviour on its way out, not a pattern to copy; new code compares ids. `sealTeam` is the one `Team` constructor and `sameTeam` the one "can these play each other" comparison (the same id, OR the same code and name — an id only ever adds a refusal). Selecting a team's own fixture is a different question with its own predicate, `isTeam` (equal ids decide when both carry one). **A flag is generated from a NATION's name; a club has none, and the competition says which.** `TEAM_KIND` (derived from the supported table's `teams` in `core/src/kinds.ts`, re-exported by `competition.ts`) is a written fact per supported competition (`nation`: the World Cup, the Euro, the Copa América, both Nations Leagues, the Gold Cup, and international friendlies, `fifa.friendly`; `club`: the six leagues, the Champions League, the Libertadores, the Concacaf Champions Cup, the Club World Cup; an unlisted competition fields clubs (a nations competition the set does not list renders its teams by name until it is written down)), never inferred from a name: `sealTeam(raw, kind)` takes it, so a club named like a region (`Monaco`) is not flagged. A club's `flag` key is ABSENT (never `''`, never 🏳️), and every line that prints a flag beside a name or a code prints it only when it is a non-empty string, with its one space (`withFlag`): nothing in its place, no double space, no code. The one exception is the ambient tokens, where a flag stands for the team on its own: the statusline's tokens (the compact live segment, the countdown, the syncing matchup) and `vibe`'s live segment are a side's flag or, for a side with none, its CODE (`ARS 2–1 CHE 50'`, `ARS vs LEE in 2d4h`), as they are with flags off. Both paths state the kind: the adapter passes its own competition to the scoreboard and standings parsers, and every cache reader passes `teamKind(competition)` (`liveMatchesFromCache`, `sealFixtures`; `SealOptions.teamKind` defaults to `club`: a kind nobody stated vouches for nothing). A PLACEHOLDER is the neutral 🏳️ a nation competition's side carries when its name is no nation (`isPlaceholderSide`); a side with no flag is a club, and RESOLVED. A team's rally cry (Mexico's "¿Y si sí?", Pumas' "¡Goya!": the table in `rally.ts`) is that team's by identity, in a competition of the cry's kind (`rallyCryFor`: the provider id, or the bundle's id-less nation by code and name in a nation competition; `isMexicoNationalTeam` is its Mexico case), never a club coded MEX; it takes the flair slot of a CLI or MCP match line (`matchFlairs` for a list, where a row with a cry reserves no phrase; `matchFlair` for one line), never on a postponed or cancelled one, the pin deciding between two (`isPinnedSide`, the one pin predicate the ambient pick asks too).
- **What a provider says ABOUT a response belongs to that response.** The season a response answered for rides on the returned array (`attachFetchMeta` / `fetchMeta`, `packages/core/src/adapters/meta.ts`), never on an adapter field: a field is shared by every call in flight, so the second call to finish overwrites what the first was told (`lastError` is "best-effort under concurrency" for that reason). A season is never part of what a request selects, and the cache-only hot path never claims to know that a newer one exists.
- **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, and at once, naming the window and writing nothing, on any window that failed, carried no account of itself, was not whole (with the count when the response knew it), or stated a season other than its own `SEASON_YEAR` (or none), and on a fixture served by two windows (naming it and both; the windows partition the calendar); its expected counts are named facts beside its windows (`GROUPS`, `TEAMS_PER_GROUP`) and core's `EXPECTED_KNOCKOUT_COUNTS`, never this run's topology or the file being replaced. Advancement comes only from the live overlay — clients never invent it from static JSON.
- **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). 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).
Narration: guides/conventions/bundle-and-bracket.md
- **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). **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 (so does `claudinho ambient --json`, the same cache through the same reader: its `next` and `line` come from the resolved pairings and fail closed the same way; and so the Claude Code plugin's band, which renders `ambient --json`'s `line` and reads no skeleton), **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 and `ambient` from a seeded cache; the plugin's band is covered by its own kit tests, `packages/plugin/hooks/*.test.tsx`, run by `claude plugin test`, not by these two); its sibling `packages/{cli,mcp}/test/club-surface-coverage.test.ts` puts a Premier League fixture (live and scheduled) through every surface and asserts a club renders by its name or code with nothing in a flag's place, no nation rename, no "Friendly", the written stage ("League", "Play-offs", the provider's words for an unknown round). **Add any new team-facing surface to BOTH tests** (and to `.cursor/rules/surface-parity.mdc`). **Deliberate exception — the MCP `fixtures://{date}` resource:** it is labeled "Static fixture list", its text names the World Cup first, and it 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.
Narration: guides/conventions/bundle-and-bracket.md
- **"Next" is not "now".** Never infer that a fixture is happening now from a `next` query: relay the returned competition, the fixture, its date and its state, and call it in play only when the returned state says so; an empty answer keeps its horizon or verdict, never an invented fixture. The same rule is in the MCP server's `INSTRUCTIONS` (`packages/mcp/src/server.ts`), naming `get_next_fixture`, where an agent reads it.
- **A request has ONE clock, and a now-relative rendering takes it as a required parameter.** The clock of a request is the one its caller gave (CLI `ctx.now`, MCP `args.now`: a test's pin, a replay), else the adapter's read clock (core `clockOf`: the one a test injects on the adapter, the wall clock in production), read ONCE per request and handed to every read and every rendering of it; `countdown` and `countdownPhrase` (core `time.ts`) and the MCP line's options (`FmtOpts.now`) take it as a REQUIRED parameter, so a renderer without a clock does not compile (`clock-required.typecheck.ts`, `fmt-opts-clock.typecheck.ts`, under the typecheck step); below the edge nothing reads `new Date()` for a rendering. A fixture dated in the real near future under a pinned clock is a wall-clock test in disguise: a red test for the clock uses a kickoff every wall clock has already passed with a request clock before it, so it is red by the seam and never by the calendar (0.11.2: `next`'s countdown turned "now" on `main` the minute the day's real kickoff passed, on a tree green an hour before).
- 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.
- **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 ambient surfaces don't. `claudinho ambient --json` is the third, for a program (the Claude Code plugin): cache-only like them, its `line` and `context` are the two surfaces' strings; the disclaimer rides on a selected competition's view as `disclaimer`, and the provider's sentence as `attribution` (English too, core's `live.data` copy), the first-run and refused objects carrying the line alone (the first run's beside its `competition: null`). The stated exception is `ambient --json`'s own: a selected competition's view carries `lang` (the CLI's resolved language) and `words` (the plugin's moment words, core's `moment.*` keys), and its `line` and `lineParts` say their countdown, syncing phrase and half-time and live tokens in that language, passed into the renderer as an argument (never read from the environment there, never in a cache key); its `+more`, the sign-off, `⚽ —`, `context`, `attribution`, `disclaimer` and each record's `line` stay English, and `prompt`, `hook` and `vibe` are unchanged.
- **Shareable snippets** (`claudinho share`, the MCP `get_share_snippet` tool, `core/src/share`) are pure, deterministic **plain-text** artifacts (no ANSI — they get pasted; every card's title names its competition and its run cue selects it, the World Cup's too, `npx @claudinho/cli --competition premier-league next Arsenal`, so the card's recipient asks what the card answered whatever they follow): English-only copy in v1 **except `share bracket`** (localized en/es/pt/fr via `ShareBracketOptions.locale`; and except the sentences a card prints for a verdict ("Not available for this competition yet"; "Some tables could not be read"; "Fixture data may be incomplete", the `note` on any match card or a bracket card) and for the span an empty card searched or what its read left out (the horizon, window and "none read" sentences, and a date card's count of rows its read did not serve), which are the same localized sentences on every surface: a card builder takes the reader's language for it; 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 [KEY]`** produces a standings card for one table (a group letter, or `A1`, `A-B`, `LEAGUE`) or, with no key, for every table (and then says so when tables are missing) — 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. A card's notes come before its body, in this order: the verdict's `note` (on a table card, its tables-missing sentence; on a live or date card whose list a surface bounded, the list's cap after it, never in the title), then the card's own (a match card: the not-live note, then the market-incomplete note; a table card: the roster note, and each table's partial line between its title and its rows; a bracket card, in both styles: its structure-only or standings note), so a card pasted into a tool's text and cut from the end loses rows, never what qualifies them; nothing but the footer follows the body.
- **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.
## Rules and their enforcers
A failure class that shipped or repeated gets a row; the enforcer is what refuses it; the limit is what it does not
prove; a row with none is the next `correct` pass. A `correct` pass is a bounded pull request on one row: it builds
the row's enforcer, proves the enforcer fails on the past mistake, and moves the row from none to that enforcer. A row
names its enforcer by file (a test by its bare name with the packages that hold it, a script by its path, a workflow
by its file name), and `packages/core/test/rule-table.test.ts` fails on a name that does not exist, so a rename cannot
leave a row behind.
| Rule | Enforcer | Refuses | Limit | Incident |
|---|---|---|---|---|
| A knockout or team-facing surface reads the bundled skeleton | `knockout-surface-coverage.test.ts` (cli, mcp) | a listed surface rendering a placeholder for a seeded resolved tie | a surface not on its list (the list is the Conventions bullet's) | v0.8.2, v0.8.6, v0.8.7 |
| A club rendered like a nation | `club-surface-coverage.test.ts` (cli, mcp) | the three placeholder flags (the white flag, the Union Jack, the black flag), a nation rename, or "Friendly" on a club fixture across the listed surfaces | the same list; a flag added to the text alone with another nation's emoji passes | 0.10.0 (the hook) |
| The live pairing lost to the bundled winner-refs | `bracket-resolve.test.ts` (core) | a projected pairing when the live fixture disagrees | a slot whose fixture the feed never served | v0.8.16 |
| A flag read from a payload or a cache | `flags-generated.test.ts` (core), `trust-parity.test.ts` (core) | the first: roster flags not generated by `productFlag`, flag text not refused by `humanLabel`; the second: the cache constructor accepting a supplied flag | neither exercises every provider constructor | #96 |
| Live and cache paths diverging | `trust-parity.test.ts` (core) | a payload the two constructors read differently | payload shapes the corpus lacks | #96 |
| A verdict key or sentence spelled by a surface instead of core | `verdict.test.ts` (core) | a copy of a verdict's key or sentence outside core | a copy, not every way to write one; an emit site that omits its spread keeps a valid shape | #126, #129 |
| A structured twin dropping a verdict the text carries | `verdict-parity.test.ts` (cli, mcp), `partial-surfaces.test.ts` (cli, mcp) | a structured answer without the verdict its text states; the partial verdict's sites, scoped to the surfaces they list | an emit site neither names | #129, 2.1d |
| A mid-request re-resolution of the competition | `selection-identity.test.ts` (core) | a `resolveCompetition` call outside the two edges or one with no argument | an edge handing in the wrong source order is its own assertion there | 2.0 |
| A hand-rewritten disclaimer | `disclaimer.test.ts` (core) | any static copy that differs from core's constant | the fixed inventory of documents and listings the test reads (a copy in a new file is not inspected); the Registry card's stated exception | 2.7 |
| A hot-path network or market read | `hotpath-latency.test.ts` (cli), its fetch assertion: zero fetches by `prompt`, `hook` and `ambient --json` on a fresh seeded cache; `hotpath.test.ts`, `hotpath-work.test.ts`, `hotpath-visibility.test.ts`, `backoff-hotpath.test.ts` (cli) | `hotpath-latency.test.ts`: a fetch by `prompt`, `hook` or `ambient --json` on a fresh seeded cache; `hotpath.test.ts`: no market read; `hotpath-work.test.ts`: the work bound; `hotpath-visibility.test.ts`: the rendering; `backoff-hotpath.test.ts`: the throttle note's timing | the fetch assertion covers a seeded cache, not every cache state; latency is the alarm row below | the statusline's design |
| Hot-path latency regressing | `hotpath-latency.test.ts` (cli) | the fastest of ten seeded `prompt` runs, and of ten `ambient --json` runs beside them, over a load-calibrated threshold | a regression alarm, skipped without the built binary; not a per-invocation bound, not the hook, not the 150 ms budget | 0.11, #164 |
| A kept file read by path | `cache-reads.test.ts` (cli, mcp) | a `readFileSync(` of a kept file outside the one bounded reader (the CLI guard); broader read patterns (the MCP guard) | a structural vocabulary check: an alternate API or spelling bypasses it; the two stated exceptions | #141 |
| A private path in a tracked file | `scripts/check-pack.mjs`, `private-doc-refs.test.ts` (core) | `docs/<name>` in any tracked text file | a bare mention of the folder is allowed by design | #125 |
| A stale README matrix | `readme-matrix.test.ts` (core) | a committed block that differs from the generated one | none stated | 2.5a |
| A counted copy drifting from the supported table | `public-copy.test.ts` (core, cli), `root-package-copy.test.ts` (core) | a framing or a count in the listed files not derived from `SUPPORTED.length` | the file lists are fixed (the guides join the count list); the matcher reads a two-digit number or the words ten to twenty, and a guide is exempt from the non-empty match | 2.5a, 2.7 |
| A Registry record or desktop manifest version drifting from the package | `server.test.ts` (mcp), `manifest.test.ts` (mcp) | a `server.json` or `manifest.json` version other than `package.json`'s | none stated | 0.10.0 |
| A bracket offered off the bundle | `supported-bracket.test.ts` (core) | a row offering one at load | none stated | 2.5a |
| A supported slug spelled outside the table | `supported-consumers.test.ts` (core) | a quoted supported slug in any source but the table | its file inventory (`src/data` excluded, a fixed set of extensions, two root scripts): a slug in a new root script is outside it; a consumer that drops a row spells nothing | 2.5a |
| A hand-written cache version in a smoke | `smoke-cache-version.test.ts` (cli) | a literal that is not `CACHE_VERSION` | none stated | 2.0 |
| The feed changing shape | `espn-canary.yml` (`.github/workflows/`) | a refused request form or a payload the parsers cannot read, daily | unbadged, gates nothing, neutral on an outage | 2.C |
| CI read on the wrong SHA | `scripts/push-and-watch.sh` | a push while a run is pending; CI read for the pushed SHA, per job | a `gh` call the script does not wrap | PR A |
| A gate step's exit lost in a pipe | `scripts/gate.sh` | one verdict per step from the command's own status | a command run outside it | #125, PR A |
| A feature-map line the product does not print | `verify-cli.test.ts` (core) | an offline line whose marker the built binary does not print | the recorded-feed lines | PR B |
| A now-relative rendering reading the wall clock while the request carries a clock | `countdown-clock.test.ts` (mcp), `market-clock.test.ts` (mcp), `market-read-clock.test.ts` (cli, mcp), `market-stale-derived.test.ts` (core), `clock-request.test.ts` (cli), `club-lines-extra.test.ts` (cli); the typecheck step on `clock-required.typecheck.ts` (core) and `fmt-opts-clock.typecheck.ts` (mcp) | a countdown, a lookup span, a default date, a market's relevance, its provider's staleness stamp or a cached signal's staleness, or the World Cup's sign-off that differs from the request's clock; a cached signal fetched after the request's clock served instead of fetched again (a kickoff every wall clock has passed, a request clock before it); a renderer or core's market relevance gate called without a clock compiling | the sites the test files reach (CLI `next`, `today`, `live`, `match`, `markets`, `share`, `follow --team`; MCP `get_today`, `get_live`, `get_match`, `get_next_fixture`, `get_market_signal`, `get_share_snippet` by id and by date); a renderer added without the type; the market caches' TTLs (the wall clock: when an entry expires, across requests) | 0.11.2 (the bump commit, CI red on `main`); the hardening PR (round 1: the market provider's stamp, the sign-off) |
| A sentence in an agent-facing prompt or skill stating a tool behavior the tool does not keep | none | (the first `correct` pass designs its enforcer or records that none can exist) | seven in one prompt, found one per round | #159 |
## Hard constraints (legal — do not violate)
- **Facts + emoji flags only.** Never add team crests, kits, player photos/likenesses, broadcast footage, or FIFA/Anthropic logos or wordmarks. The one exception: Anthropic's Claude mascot, drawn as three rows of text art in the Claude Code plugin's goal and full-time celebration only, in the engine's `claude` theme colour; it is removed if Anthropic asks.
- Keep the disclaimer, *"Not affiliated with FIFA, any confederation, league or club, or Anthropic."*, on user-facing surfaces. The Claude Code plugin's band and toasts omit the disclaimer and the provider's attribution as `prompt` and `hook` do (one-line ambient surfaces; `claudinho ambient --json` carries the disclaimer as `disclaimer` and the attribution as `attribution`); the plugin's `/claudinho` reply relays both from a view, and the `/claudinho match` reply leaves the attribution and the disclaimer to the pane it opened only when that pane opened on a surface that draws it and holds both (the pane draws both under the CLI's rows); otherwise it is the bare reply, relaying them as `/claudinho` does (each when the view carries it); its manifest, the marketplace entry and its README carry it. It is ONE constant, core's `DISCLAIMER` (`packages/core/src/disclaimer.ts`), which every runtime surface imports and never spells; a listing host's composition is `disclaimerLine(host)` (the constant with `, nor with <host>` before its period); the static copies (the listings, the public Markdown) are pinned to it by `packages/core/test/disclaimer.test.ts`, and the MCP Registry's 100-character card is the one stated exception (the fan line alone).
- The name stays cheap to change: add no new load-bearing use of `claudinho` beyond the ones there are (for example the binaries, the `CLAUDINHO_*` variables, the npm scope, the MCP server's name, the Registry, plugin and manifest names, the config and cache paths), and no logo or wordmark of Anthropic, FIFA, a confederation, league or club, or a listing host in the product's output or assets (the README's badges, which carry a platform's mark by that platform's own convention, are not the product). The one exception: Anthropic's Claude mascot, drawn as three rows of text art in the Claude Code plugin's goal and full-time celebration only, in the engine's `claude` theme colour; it is removed if Anthropic asks.
- Attribute data providers; respect their rate limits. `claudinho ambient --json` carries the attribution as `attribution` (when the snapshot names a provider and the line is neither the sign-off, `idle`, nor nothing known, `empty`; `degraded` does not decide), relayed by the plugin's `/claudinho` reply.
- **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).
## Pre-PR self-review (run before declaring a change "done")
This is the lens an external reviewer uses — apply it yourself first. For changes
touching money/legal/external APIs, also run an **independent adversarial pass**
(e.g. a reviewer subagent with fresh eyes on the diff) and self-classify any
findings **P1/P2/P3**. Apply each item to the behavior and contracts affected by the change;
use "Validation scope" for the required checks.
1. **Verify external contracts against ground truth.** For any new API/integration,
fetch a *real* response and confirm the parser **and the test fixtures** match it.
Never ship against an assumed payload shape — green tests built on a wrong fixture
prove nothing.
2. **Apply the change to *every* surface.** Enumerate them: CLI (text **and** `--json`),
MCP (structured `data` **and** text), statusline/hook/`ambient --json`, share, and the READMEs. A behavior
that lands on 3 of 4 surfaces is a bug — and "every surface" means each surface's *args*
(tz/locale/flags) are threaded, not just that the surface exists.
3. **Audit the whole touched area against the Hard Constraints — including pre-existing
code, not just the diff** (e.g. "attribute data providers" applies to *every*
provider, not only the one you added).
4. **Adversarial failure-mode pass per new code path:** empty / missing / malformed
input; transient vs. permanent error (never cache a transient failure as a real
"no result"); duplicate / ambiguous data; timeout / deadline / concurrency. Default
to **fail-closed**.
5. **State the worst-case latency/cost of any default-on path** under realistic load
(e.g. "N sequential fetches × T timeout") and bound it (deadline + cache).
6. **Sync the meta in the same change:** READMEs, the Cursor rules (`.cursor/rules/`), MCP
tool descriptions, and release guards (`publish.yml`, pinned tool versions). Flag any
claim that went stale.
## Change discipline
**Changing a shared rule**
- **Put the rule where every path reaches it, then delete the other copy.** Live and
cache paths must use the same rule.
- **Search for siblings before calling a class closed:** other timestamps, the hook
beside the statusline, cache constructors beside provider constructors.
**Tests**
- **Make a new regression test fail before trusting it.** Temporarily revert the rule
and confirm red, then restore it. Preserve unrelated edits.
- **Pin the CALL, not just the function.** For a call-site regression, temporarily remove
the call and confirm red, then restore it.
- **Never assert wall-clock time.** Assert an observable consequence: put a valid item
just past the cap and prove it is never reached.
- **Escape invisible characters in fixtures** so their meaning survives copying and review.
**Before saying it is done**
- **Run the checks required by "Validation scope" AND read the output.**
- **For a refactor affecting feed output, diff real-feed output against the base branch**,
key order included. Report if the feed prevents verification.
- **Wait for CI on the SHA you pushed.** `gh run watch` on a queued run returns
success; check `headSha` matches.
## Definition of Done (per user-facing feature, not per PR)
Before implementing a user-facing feature, write 3–5 acceptance criteria **from the
user's point of view**, plus an explicit statement of what the change does NOT cover.
Keep them in the PR description before the first commit; if no PR exists yet, draft that
description locally and use it when opening the PR. A feature spanning several PRs must
meet the same criteria across the whole feature. Verify them on a real terminal:
- The output is **unambiguous** to read (e.g. "which calendar day is this match?" across a 3-week span).
- 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).
- 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.
- It **fails closed** (degraded feed → honest TBD/notice, never an invented or stale fact).
Scope these up front so the feature ships whole, not in dot-release pieces.
## Release readiness — run `scripts/release-qa.sh` before tagging
"Test it on a real terminal first" is executable: **`scripts/release-qa.sh`** (`pnpm release:qa`)
renders *every* user-facing surface against the **live feed, across two timezones and all four
locales**, and ends with tripwires for known regression classes (bracket shows a calendar date,
`tz` is actually threaded, disclaimers intact; it SKIPs rather than fails on a degraded/unreachable
feed, so a network blip never blocks a release). Build, run it, **read the output**, then tag. It
does not replace the eyeball — its job is to put every surface in front of you so nothing ships
unseen. (It covers CLI/share rendering; MCP arg-threading is guarded by
`packages/mcp/test/tools.test.ts` — keep that green too.) It exports no competition: it FOLLOWS
the World Cup in a temporary config directory it creates and removes (`claudinho follow
world-cup`, as a user would: the mode line names no source), and `CLAUDINHO_COMPETITION=<alias|slug>
pnpm release:qa` renders another (the environment wins over the saved choice). Its header names
the competition the built CLI RESOLVED, read back from `--json`, never assumed. After that pass it
renders a short CLUB pass: the Premier League followed in a second temporary config directory
(`today`, `live`, `next Arsenal`, `table`, `table LEAGUE`, `share next Arsenal`, the Portuguese
table, and `prompt` against a cache seeded by the smoke's own helper, `scripts/statusline-seed.mjs`,
with the refreshers it would start counted by `scripts/spawn-count.mjs`: none), never the bracket;
a between-editions league is a valid render, an outage is reported as one, and the club pass is
skipped when the header says the environment chose the competition. What it decides about the
competition (the bundle-drift tripwire runs only when the competition the built CLI RESOLVED is
the bundled one; an unresolved competition or an unreadable verdict FAILS, never SKIPs) lives in
`scripts/release-qa-lib.mjs`, tested offline by `packages/core/test/release-qa.test.ts`.
## Release cadence — batch, don't dot-release per fix
Two kinds of release, and only one is urgent:
- **Hotfix-now** — live data *correctness* only (a wrong score/standing *during* a match,
or a feed outage rendering as authoritative). Ship immediately.
- **Everything else** — UX, polish, cosmetics, follow-on sub-features — **batch** onto the
working branch and release as a single bump. Stack review fixes for the same feature into the
same PR before merge.
Every release carries real toil (a multi-file version bump, and for MCP-affecting changes a
`.mcpb`/Smithery re-publish). Fewer, fuller releases cut that directly. When unsure, accumulate.
## Don't
- Don't put API keys in client packages — keys live **only** in the gateway.
- Don't block the statusline hot path on the network — read from the local cache (<150ms).
- Don't cite anything under `docs/` from a tracked file (code comments, Cursor rules, templates, READMEs). That folder is maintainer-private and gitignored; a public reference leaks a private path and goes stale when the private tree changes. `scripts/check-pack.mjs` (run in CI) fails on any `docs/<name>` reference.