agentleFS
arturogarridoarturogarrido/claudinho/AGENTS.md

claudinho

AGENTS.md35 starsChanged 34 days agoMITView on GitHub

Guidance for AI coding agents working in this repository. (Standard AGENTS.md; Claude Code reads it via CLAUDE.md.) 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. - Complete the requested work through validation and…

  • Reads credentials
  • Installs packages
  • Commits and pushes

The file

Show its rules and text

We split this AGENTS.md into the rules it gives an agent

16 rules. About 0 are common, 9 are distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈9

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read the files and applicable rules needed for the task.

    line 37
  2. Distinctive:

    If a rule blocks authorized work, cite the exact rule and explain the conflict.

    line 40
  3. Distinctive:

    Treat memory, prior reviews, and model recommendations as context.

    line 41
  4. Distinctive:

    Use an independent reviewer for the cases required under "Pre-PR self-review".

    line 47
  5. Very specific:

    Keep that local path: packages/core/test/biome-schema.test.ts rejects a pinned URL or a missing bundled schema.

    line 94
  6. Distinctive:

    Add tests for changed behavior and failure modes.

    line 122
  7. Very specific:

    Run this from the repository root.

    line 128
  8. Very specific:

    Commit, open the release PR and squash-merge it.

    line 163
  9. Distinctive:

    After each of the last three actions, read the three again: an action that worked lands in the first case.

    line 258
  10. Very specific:

    Adding or changing an MCP tool: every tool declares an outputSchema and returns structuredContent (packages/mcp/src/server.ts).

    line 277
  11. Distinctive:

    Use the model actually in use, not a hardcoded one.

    line 309
  12. Very specific:

    Read the model from the current session.

    line 318
  13. Distinctive:

    Do not treat a previous review, memory, or local branch name as current truth.

    line 332
  14. Very specific:

    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 …

    line 352
  15. Distinctive:

    Use the model-selection guide as a starting point, then compare on representative project tasks at the same supported effort before tuning …

    line 355
  16. Distinctive:

    Keep repository-specific contracts and required gates.

    line 364

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

AGENTS.md — Claudinho

Guidance for AI coding agents working in this repository. (Standard 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 maintainer's machine updated; 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 ≥ 22 (the published packages' floor; building needs tsup 9's range, ^22.18.0 || ^24.11.0 || >=26.0.0, the root package.json's engines), 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

PathPackageRole
packages/core@claudinho/coredomain 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/clithe claudinho binary (CLI + statusline + hook + ambient + cache/refresher)
packages/mcp@claudinho/mcpstdio 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); the folder holds only what installs (Claude Code copies it whole), pinned by packages/core/test/plugin-files.test.ts; its tests live in packages/plugin-tests (next row), run in CI's plugin job, which validates both folders under the pinned Claude Code CLI
packages/plugin-tests—the plugin's kit: its tests (hooks/*.test.tsx) and their helpers (hooks/world.tsx, hooks/width-corpus.ts), laid out as the plugin is and kept out of the folder that installs; scripts/plugin-test.sh copies the plugin and the kit into one temporary folder and runs claude plugin test there (with claude on PATH)
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/notifierplanned — 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 capability matrix in the root README and the CLI's README (between <!-- competitions:start --> and <!-- competitions:end -->) from the supported table; packages/core/test/readme-matrix.test.ts fails when a committed block differs
  • bash scripts/plugin-test.sh — the Claude Code plugin's kit tests (packages/plugin-tests) against the plugin folder, under the claude on PATH (CI pins its version); claude plugin validate packages/plugin checks the plugin itself
  • 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 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 22 floor, 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 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.

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/server.json (its version and its npm package pin) — the MCP Registry record carries its own; a vitest guard (packages/mcp/test/server.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, open the release PR and squash-merge it. Then tag the commit the merge made on main, never the release branch's own commit: the squash is a NEW commit, absent from a local clone until a fetch, and a bare git tag vX.Y.Z tags HEAD, which in the release worktree is the branch's commit. With <n> the release PR:

    git fetch origin +refs/heads/main:refs/remotes/origin/main &&
      sha=$(gh pr view <n> --json mergeCommit --jq .mergeCommit.oid) &&
      git merge-base --is-ancestor "$sha" refs/remotes/origin/main &&
      git tag vX.Y.Z "$sha" && git push origin refs/tags/vX.Y.Z:refs/tags/vX.Y.Z

    The fetch names its refspec, so it refreshes origin/main whatever the clone's remote.origin.fetch holds (a --single-branch clone's does not name main). The push names both sides of its refspec, so a remote.origin.push mapping in the clone's config cannot send the tag to another name or force it there, as it can a bare git push origin vX.Y.Z. Each command runs only when the one before it succeeded. When the chain stops before git tag (the fetch or gh failed, the PR is not merged, or main does not hold its merge commit), nothing is tagged: check that gh pr view <n> --json state says MERGED, then run the block again. When git tag or the push fails, stop: this step never deletes or moves a tag on origin; read the tags as "When the tag step stops at git tag or the push" below says, and do only what its case says. The workflow runs on a v* tag push alone (it has no manual trigger). It first fails on a commit main does not contain (a tag on the branch's commit publishes nothing), then gates (build, typecheck, test, lint, and a refs/tags/v* ref whose version every package carries), 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. A failed run is retried on the same tag with "Re-run failed jobs" (gh run rerun <id> --failed): pnpm -r publish skips a version npm already has, the Release step skips a Release that exists, and mcp-registry skips a version the Registry has.

  4. Once both jobs are green, update the maintainer's own machine and read each version back. None of it updates by default: an npm global install never updates itself, and Claude Code leaves plugin auto-update off by default for a third-party marketplace such as this one.
    • The CLI: command -v claudinho names the copy that runs. For npm's (the usual case), npm i -g @claudinho/cli@latest; for a copy another installer put there (pnpm or Volta, for example), that installer's update. Then claudinho --version prints the new version.
    • The plugin: claude plugin marketplace update claudinho (refreshes the marketplace's listing only), then claude plugin update claudinho@claudinho, then claude plugin list shows the version in packages/plugin/.claude-plugin/plugin.json on main. The marketplace follows the default branch, not the tag: users get main's plugin, which is the tag's only while main carries no later plugin bump. The plugin's version is its own, not the npm version; a session already open keeps the old one until /reload-plugins or the next session.
    • The MCP server needs nothing when it runs as npx -y @claudinho/mcp and no other copy is installed: npx asks the registry for the newest version of a bare package name at each start of the server. A copy in the project the server starts in (npm ls @claudinho/mcp there) or a global one (npm ls -g @claudinho/mcp) is used as installed instead, the project's first: update every copy the two list (npm i @claudinho/mcp@latest in that project, npm i -g @claudinho/mcp@latest), read each back with the same npm ls, then restart the server.

When the tag step stops at git tag or the push. A failed git tag does not prove a tag exists: it can fail without making one. Read three things first, in the clone the block ran in, with <n> the release PR (the ^{} is quoted: zsh with EXTENDED_GLOB set reads an unquoted one as a glob and runs nothing):

gh pr view <n> --json mergeCommit --jq .mergeCommit.oid
git ls-remote origin refs/tags/vX.Y.Z 'refs/tags/vX.Y.Z^{}'
git show-ref --verify -d refs/tags/vX.Y.Z

The first prints the merge commit. The second reads the tag on origin and the third the local one. Only a line whose name is exactly refs/tags/vX.Y.Z or refs/tags/vX.Y.Z^{} counts: git ls-remote matches a name by its end, so a branch named refs/heads/refs/tags/vX.Y.Z prints too, and it is not the tag. Counted that way, both print alike: no line when there is no such tag (the third then fails with not a valid ref), one line for a lightweight tag (the object it names), and two for an annotated one, whose target is the line ending ^{} (the other names the tag object: this repository's annotated v0.1.0 prints 328ad4e for the object and 748dfd6 for its commit). A tag is "on the merge commit" when that line names it, and "elsewhere" when it names anything else. When gh or git ls-remote fails, nothing is decided: read again once it answers. Then the tag on origin decides first:

  • origin has the tag on the merge commit: the release is tagged, and the step ends here; nothing is pushed or run again. The publish workflow runs on that tag (step 4 waits for its two jobs). A local tag elsewhere would stop a later git tag: git tag -d vX.Y.Z removes it.
  • origin has the tag elsewhere (an earlier bare git tag was pushed): stop; the step ends here. Moving a pushed tag is the maintainer's decision, never an agent's: the tag is public, and the workflow may already have published from it (a commit on main passes its check). Deleting the local tag does not get past it. In a clone that fetches tags (git's default) and holds the tag's commit (the release worktree holds the release branch's head), the block's fetch brings the tag back and git tag stops; in any other clone git tag succeeds and the push is rejected because the tag already exists on origin. What the maintainer reads before deciding: the publish run on the tag (gh run list --workflow publish.yml --branch vX.Y.Z), whether npm has the version, for each of the three packages (npm view @claudinho/core@X.Y.Z version, and the same for cli and mcp: each prints the version, or fails with E404; the workflow publishes them one at a time, so one can be on npm while another is not), and the GitHub Release (gh release view vX.Y.Z).
  • origin has no tag, and the local tag is on the merge commit (the push failed): git push origin refs/tags/vX.Y.Z:refs/tags/vX.Y.Z.
  • origin has no tag, and the local tag is elsewhere: git tag -d vX.Y.Z, then run the block again.
  • Neither has the tag: git tag failed without making one, and printed why: a signing setting (tag.gpgSign) whose signer failed or whose message was left empty, or a ref lock (cannot lock ref: another git process, or a stale lock file). Fix that cause, then run the block again.

After each of the last three actions, read the three again: an action that worked lands in the first case. Each of those actions is taken once: when the reads land in the same case again after it, stop and hand them to the maintainer.

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.

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.

The .mcpb bundle is retired (0.13.0). It was a Claude Desktop Extension published only to Smithery, which stopped accepting updates on Oct 17, 2026 and ends its accounts, API and CLI on Oct 31, 2026. Nothing builds or publishes one any more; scripts/check-pack.mjs still refuses a .mcpb inside a package.

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 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 and 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 and maintaining agent instructions. 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 four keyword lists (packages/{cli,mcp,core}/package.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-tests/hooks/*.test.tsx, run by claude plugin test through scripts/plugin-test.sh, 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.

RuleEnforcerRefusesLimitIncident
A knockout or team-facing surface reads the bundled skeletonknockout-surface-coverage.test.ts (cli, mcp)a listed surface rendering a placeholder for a seeded resolved tiea 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 nationclub-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 surfacesthe same list; a flag added to the text alone with another nation's emoji passes0.10.0 (the hook)
The live pairing lost to the bundled winner-refsbracket-resolve.test.ts (core)a projected pairing when the live fixture disagreesa slot whose fixture the feed never servedv0.8.16
A flag read from a payload or a cacheflags-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 flagneither exercises every provider constructor#96
Live and cache paths divergingtrust-parity.test.ts (core)a payload the two constructors read differentlypayload shapes the corpus lacks#96
A verdict key or sentence spelled by a surface instead of coreverdict.test.ts (core)a copy of a verdict's key or sentence outside corea 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 carriesverdict-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 listan emit site neither names#129, 2.1d
A mid-request re-resolution of the competitionselection-identity.test.ts (core)a resolveCompetition call outside the two edges or one with no argumentan edge handing in the wrong source order is its own assertion there2.0
A hand-rewritten disclaimerdisclaimer.test.ts (core)any static copy that differs from core's constantthe fixed inventory of documents and listings the test reads (a copy in a new file is not inspected); the Registry card's stated exception2.7
A hot-path network or market readhotpath-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 timingthe fetch assertion covers a seeded cache, not every cache state; latency is the alarm row belowthe statusline's design
Hot-path latency regressinghotpath-latency.test.ts (cli)the fastest of ten seeded prompt runs, and of ten ambient --json runs beside them, over a load-calibrated thresholda regression alarm, skipped without the built binary; not a per-invocation bound, not the hook, not the 150 ms budget0.11, #164
A kept file read by pathcache-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 filescripts/check-pack.mjs, private-doc-refs.test.ts (core)docs/<name> in any tracked text filea bare mention of the folder is allowed by design#125
A test or test helper shipped in the installed pluginplugin-files.test.ts (core)a tracked file under packages/plugin other than the manifest, the icon, hooks/hooks.json, hooks/register.tsx, types/index.d.ts and the README; a kit with no test file, or with a file that would replace a plugin filean untracked file in a local checkout (the marketplace and the directory read the repository)the directory's file list, Oct 19, 2026
A stale README matrixreadme-matrix.test.ts (core)a committed block that differs from the generated onenone stated2.5a
A counted copy drifting from the supported tablepublic-copy.test.ts (core, cli), root-package-copy.test.ts (core)a framing or a count in the listed files not derived from SUPPORTED.lengththe 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 match2.5a, 2.7
A Registry record version drifting from the packageserver.test.ts (mcp)a server.json version or npm pin other than package.json'snone stated0.10.0
A Registry record listing an environment variable the server does not readserver-env.test.ts (mcp)a name in server.json's environmentVariables never read while the server, imported fresh, answers one call to each tool with no optional argumenta read, not an honoured value (each variable's own test does that: lang-env.test.ts for CLAUDINHO_LANG); a read only another path makes fails it closed0.4.2 to 0.13.0 (CLAUDINHO_LANG)
A bracket offered off the bundlesupported-bracket.test.ts (core)a row offering one at loadnone stated2.5a
A supported slug spelled outside the tablesupported-consumers.test.ts (core)a quoted supported slug in any source but the tableits 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 nothing2.5a
A hand-written cache version in a smokesmoke-cache-version.test.ts (cli)a literal that is not CACHE_VERSIONnone stated2.0
The feed changing shapeespn-canary.yml (.github/workflows/)a refused request form or a payload the parsers cannot read, dailyunbadged, gates nothing, neutral on an outage2.C
CI read on the wrong SHAscripts/push-and-watch.sha push while a run is pending; CI read for the pushed SHA, per joba gh call the script does not wrapPR A
A gate step's exit lost in a pipescripts/gate.shone verdict per step from the command's own statusa command run outside it#125, PR A
A feature-map line the product does not printverify-cli.test.ts (core)an offline line whose marker the built binary does not printthe recorded-feed linesPR B
The engines floor not tested at the floorengines-floor.test.ts (core)the published packages stating different floors, or a floor not of the form >=N; a floor job not named for it, or one whose setup-node steps are not exactly two, Node N to build and the floor itself, N.0.0, to runa source-text guard over the shapes the workflows use today: a line naming actions/setup-node it did not read a step from, or a version that is not a plain number, is refused; a setup reached without that name, and what the job's commands do with the Node, are outside itPR #206 (round 1: the pin had no test)
A now-relative rendering reading the wall clock while the request carries a clockcountdown-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 compilingthe 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 keepnone(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, the live pass, the maintainer's machine updated). 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.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.