AGENTS.md · git:20260917.2fb463b · 2026-09-17 · sha256 2b4042fe7d2f0be0
AGENTS.md git:20260917.2fb463bA
Immutable. This exact content is served forever at /api/v1/blob/2b4042fe7d2f0be0.
# Long Run Hybrid Coach repository rules
This is a Codex-native product. The **product** must not call an LLM API or require an
OpenAI API key -- not `garmin_coach_loop/`, not the CLI, not the gateway, not `evals/`,
not `tests/`. The model doing the coaching is the client's, and it stays the client's.
One exception exists, and it is a separate deployable rather than a loophole: the public
demo under `entrypoints/demo/`, which serves one synthetic athlete to anonymous visitors
and needs a model of its own because there is no client on the other end of it. It runs as
its own Railway service with its own secret, no provider credentials and no volume; it
reuses this package's contracts and projectors instead of reimplementing them; and nothing
in `garmin_coach_loop/` may import it or acquire its dependency. It is not an entry to
anybody's plan -- no store, no owner and no write path is reachable from it -- so invariant
10 below does not bind it, and what it must never become is a second way to coach a real
athlete. `tests/test_demo_boundary.py` holds every clause of this paragraph that a test
can hold.
## Dependencies
The product had no Python dependencies at all until 2026-09-17, and the reason that
stopped being right is worth stating rather than the rule alone. MCP is not this
repository's protocol. Hand-writing it meant owning every revision of somebody else's
wire format, and the revision that made that untenable -- 2026-07-28 -- is not another
value in a version list but a different message shape, so the alternative to a dependency
was a second protocol implementation maintained here forever (issue #352).
So there is exactly one dependency, pinned exactly, in `requirements.txt`:
- **`mcp`**, the official Model Context Protocol Python SDK. It owns the protocol and
wire layer behind `/mcp` -- revisions, the 2025 `initialize` handshake, the 2026-07-28
per-request envelope and `server/discover`, JSON-RPC framing, and serving both eras from
one endpoint. It carries no LLM client and no provider credential, so the rule above is
untouched: the model doing the coaching is still the client's.
Adding a second one is a decision of the same size, not a follow-on from this one. The
question to answer first is whether the thing being imported is a standard this product
merely speaks, or product behaviour it should own. Coaching, validation, the store, the
delivery boundary and identity are the second kind and stay stdlib-only.
**Nothing installs from `requirements.txt`.** It is the intent -- one name, one version,
and the reason. `requirements.lock` is what every build installs: the distributions that
pin resolves to, each with the sha256 of the artifacts allowed to satisfy it, generated by
the command recorded in its own header. The `Dockerfile` and every workflow job install it
with `--require-hashes`, so a substituted byte fails the build rather than entering the
process that holds every connected athlete's credential, and there is deliberately no
second unhashed path to fall back to.
**Upgrading the SDK is a decision with a written trigger**, not a version comparison: a
protocol revision this product should serve, or a security or compatibility fix. Not
latest. The rule, the five-step ceremony and the owner who notices are in
[docs/ops/upgrade-the-mcp-sdk.md](docs/ops/upgrade-the-mcp-sdk.md); `change_gates.py`
classifies a moved pin as a transport change, and `scripts/mcp_protocol_envelope.py`
prints what the candidate SDK would change on the wire before it is rolled.
`python3 -m unittest discover -s tests -p 'test_*.py'` and
`python3 scripts/check_repo_safety.py` now need the lock installed; CI installs it, and so
does the deployed image. `/readyz` reports the version that was actually resolved
(`mcp_sdk_version`), because a pin says what the build asked for and only the running
container says what is answering.
## Repository invariants
1. Keep the product runnable without importing PersonalOS. PersonalOS may be a
migration reference, never a runtime dependency.
2. Keep credentials, raw provider payloads, GPS tracks, FIT activities, private
contexts, plans, approvals, receipts, and provider state outside the
repository. Only anonymous fixtures may be committed.
3. Treat missing, stale, partial, and failed reads as unknown. Never convert
them to zero or evidence of recovery.
4. The model owns coaching judgment. Deterministic code owns data acquisition,
reconciliation with identity-backed actuals, validation, persistence,
approval binding, idempotency, provider writes, read-back, and delivery state.
5. Deterministic validation must not become a shadow coach. Block only when an
action is structurally invalid, contradicts verified state or identity,
invents unsupported precision, crosses an authorization or delivery boundary,
or conflicts with an explicit positive safety signal. Missing, stale, partial,
or failed optional evidence may lower confidence but must not by itself force
rest or human review, or block an otherwise valid coaching action.
6. Before adding a blocking validator, document the exact invariant and concrete
harm, why a warning, model judgment, or narrower capability boundary is
insufficient, which valid workflows remain possible, and the false-positive
cost. Add both a harmful-case regression and a false-positive control. Prefer
provenance, warnings, bounded writes, and targeted checks over blanket denial.
7. Publishing requires approval bound to the exact proposed delivery. Only
product-owned workouts may be written or updated.
8. Report only delivery evidence the product can observe. The contract is the
owner of valid delivery states; an earlier state never proves a later hop.
9. Do not diagnose. Pain, illness, chest pain, dizziness, or unusual symptoms
require a lower-risk human decision.
10. Coaching capability is entry-agnostic: every entry, including a new one,
must be able to express any coaching act the validation layer accepts.
Entries differ only in data sources and in operator tooling.
11. Invariant 5 binds the validator; this binds the prompt. A Skill or hosted
instruction must not become a shadow coach either. It owns only the
product-specific orchestration a competent model cannot infer: what the
source of truth is, which boundary needs an explicit confirmation, and what
the product may claim to have observed. Field semantics belong to
`contracts/`, training judgment to `hybrid_training.md` served beside it as
its own prompt, structural and authorization rules to the validator, and the command surface
to README. An observation is never mapped to an assumed cause and a fixed
adjustment: `none_found` means no matching evidence was observed, never that
the athlete's week was too full.
12. A new capability changes evidence, context, schema, or tool descriptions.
Changing a coaching instruction instead is the exception and carries the
burden: name the concrete, reproducible eval failure it fixes, and why a
better field description, context shape, or tool contract cannot fix it. A
single dogfood incident is not that failure. No fixed threshold, progression
percentage, or decision tree enters an instruction unless it is a structural
or safety invariant. When deleting an instruction leaves the coaching evals
and the safety boundary unchanged, keep it deleted.
13. Everything the model reads is one finite budget: tool descriptions, input
schemas, the orchestration prompt, the Skill, and every context field. A
client is handed all of it before the first turn and carries it through the
conversation, so an addition is paid for by every later turn. Growth is
therefore accounted for, not assumed: adding surface means saying what it
buys, and adding it to the orchestration prompt means naming the paragraph it
replaces.
14. A tool is one act with one set of defaults. Do not merge operations whose
side effects, destructiveness, or defaults differ — a single tool whose
unstated field means "not stated" on one path and "as prescribed" on another
invents data on behalf of the athlete, and one whose annotations must
describe both paths can only describe them dishonestly. Equally, do not split
one act into a sequence the model has to rediscover. Fewer tools is not the
goal; each tool being truthfully describable in its own sentence is.
15. Prefer changing what the model reads over adding to what it must choose
between. A better field description, a clearer context shape, or a tighter
input contract is cheaper than a new tool and cannot be called at the wrong
time. When a new surface is genuinely the answer, an eval case that fails
before it and passes after is what shows it was.
16. A comment reading `archived issue #NN` names an issue in the archived
private repository this product migrated from; the number does not resolve
here and is provenance for finished work, never a link to follow. Cite this
repository's own tracker as plain `issue #NN`. Before adding a new citation,
open the number and confirm its topic actually matches what you are writing
next to — the archived repository used the same low numbers this one has
since reissued for unrelated work, so a remembered number is not enough.
## Product boundaries and prioritization
A real bug is not automatically the next bug. Keep **existence**, **severity**, and
**priority** separate so a review does not turn into an edge-case generator.
- Start every finding with the normal user-visible scenario: who does what, what
they see, how often that path is expected, and what goes wrong. An internal
inconsistency without a material user consequence is not a current blocker.
- Product boundaries are decided once and then treated as constraints. A later
edge case may test the boundary, but does not reopen it or invent a new product
model unless normal use demonstrates that the boundary is wrong.
- For coaching, keep two primary truths: **the Coach prescription (PlanState)**
and **actual execution**. A provider calendar is a delivery projection, not a
third coaching source of truth. Do not build continuous bidirectional calendar
reconciliation or conflict-resolution machinery for manual provider edits.
Product-owned future delivery should follow the latest confirmed PlanState;
past calendar entries are history. What the athlete actually did remains
evidence even when it did not match a planned session.
- Classify work before changing code:
- **Blocker:** normal flow is broken/materially misleading; meaningful user data
can be lost or corrupted; there is a material authorization, privacy,
security, or safety problem; or public release cannot proceed safely.
- **Current:** common or material user friction / coaching-quality loss that
belongs to the current product lane.
- **Scheduled:** a real but uncommon, recoverable, or out-of-band failure. File
it with a trigger and priority, then finish the current higher-value work.
- **Deferred:** hypothetical/narrow race or low-impact cleanup without observed
product harm. Preserve the evidence and the condition that would reopen it;
do not build architecture for it now.
Security/privacy issues may remain blockers even when the exploit path is rare;
rarity alone is not a reason to ignore high-impact boundaries.
- When review discovers a scheduled/deferred issue, record it without cascading
into adjacent fixes. Finish the current issue/PR first. A new finding may
interrupt only when it independently meets the blocker/current threshold.
- Do not let edge cases create new state machines, scores, warnings, tools,
confirmation steps, or reconciliation systems unless the normal product flow
needs them. Prefer the smallest repair at the layer that owns the fact.
- Every issue or PR intended to interrupt the roadmap must state **before →
after** in user terms and name its priority. Reports must list **current
blockers/current work separately from scheduled/deferred findings**; never mix
them into one undifferentiated bug list.
## A step the athlete has to take is the owner's decision
Anything that costs the athlete an action -- a client tool approval, an OAuth prompt, a
confirmation turn, a page to read, a reauthorization -- is a product decision, not an
implementation detail. Before landing a change that adds one, or that changes what a
client asks for, show the owner the concrete before → after in user terms (which client,
which screen, how many approvals for the same task) and the tradeoff, and wait for an
answer. Naming the change security work, compliance work, or a platform review
requirement does not waive this: when a requirement and the approved experience conflict,
report the conflict in the same before → after form and let the owner choose. Removal is
the same decision in the other direction -- a disclosure or confirmation the owner asked
for does not come out quietly either.
Issue #408 and issue #409 are what this rule is made of. A truthful annotation change
added a client approval to every preview, and the release that removed a warning page
removed the only place an athlete was told which client had been authorized. Both were
defensible in isolation and neither was the owner's call to skip.
## Version numbers
`PRODUCT_VERSION` (`garmin_coach_loop/gateway.py`), `server.json` and the Codex
plugin manifest carry one number, and a test holds the three equal. What the parts
mean was settled by the owner on 2026-09-09, against this repository's own prior
habit of bumping the minor for every release that changed anything:
- **Minor is a product-level release the owner declares**, not something a change
earns. 1.3 to 1.4 was one. Reserving it is the point: a minor spent on an
ordinary release is a minor unavailable for the next real one.
- **Patch carries everything else**, including a changed tool description, a
renamed response field, and a moved `instructions_sha256`. This product does not
maintain backward compatibility before it is stable, so a rename is not by itself
a reason to reach for a larger number.
- **The number does not decide whether a submission is a new reviewed surface.**
The changed surface does. A patch that moves `tool_catalogue_sha256`,
`instructions_sha256` or `skill_sha256` still creates one, and still cannot roll
under a pending review's snapshot (issue #182). Read the digests, not the version.
## Development and release gates
The repository has one inexpensive local feedback path and two correctness boundaries:
```bash
# default local feedback: committed changes against origin/main, plus staged,
# unstaged and untracked work in this checkout
python3 scripts/test_selection.py
# the reviewed model-facing surface, compared against a base ref through both
# protocol eras -- tool names, descriptions, schemas, annotations, instructions,
# prompts, and the catalogue digest
python3 scripts/mcp_contract_equivalence.py --base origin/main
# see the exact manual gates before touching a live client
python3 scripts/change_gates.py --base origin/main
# what the installed MCP SDK puts on the wire, against the recorded envelope --
# negotiated revisions, initialize capabilities, server/discover, error semantics
python3 scripts/mcp_protocol_envelope.py
# the confidence boundary kept for every pull request and every main push
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/check_repo_safety.py
```
`test_selection.py` runs only the directly affected tests when it has a mapping. An
unknown executable change falls back to the full suite; documentation-only changes run
no product tests. This is a developer feedback optimization, not evidence for merge or
release. Pull requests and `main` continue to run the full suite, repository safety, and
the clean-tree check in CI. CI concurrency cancels an older run for the same pull request
or branch when a newer commit supersedes it.
`change_gates.py` is the mechanical decision point for expensive manual work:
| Change surface | Additional gate | Why |
| --- | --- | --- |
| OAuth, gateway, provider delivery, delivery-boundary, or local MCP client code | Corresponding live smoke | The provider/auth hop is not proven by unit tests alone. |
| Tool catalogue, input/output schema, annotation, or served prompt/instructions | Real client acceptance, Scan Tools, and a new plugin version before resubmission | These are model-facing or reviewed MCP bytes. |
| The wire module, or a moved dependency pin (`requirements.txt`, `requirements.lock`) | The dual-era acceptance run, and no resubmission | The SDK owns which protocol era answers; a refused 2026-07-28 client falls back to 2025 silently. It cannot move this repository's reviewed bytes, so it must not trigger a review. |
| Canonical Skill only | Client acceptance for Skill-consuming entries; no Scan Tools for the current MCP-only OpenAI submission | The Skill is packaged separately from the MCP snapshot. |
| Submission packet, registry entry, or plugin manifest | A new plugin version before resubmission; no Scan Tools on its own | They are the bytes a reviewer or the registry receives, not the served tool catalogue. |
| Internal code, tests, docs, release notes, or CI-only changes | No live ceremony | They do not change a live provider or reviewed client surface. |
| A package file no list names | Reported as unclassified: live smoke and client acceptance until it is named | Silence is not evidence that a new module is internal. |
The tool-catalogue row is decided from the digest rather than from the diff: `change_gates.py`
builds `tool_catalogue_sha256()` at `--base` and at this checkout and asks for Scan Tools when
the two differ, whichever file moved them, keeping the line markers in `mcp_transport.py` as
the fallback for a base it cannot build.
The table is exhaustive by construction for `garmin_coach_loop/`: `change_gates.py`
names every `.py` and `.md` file in the package as live, model-facing, diff-gated or
internal, and `tests/test_process_gates.py` fails when a file is in none of them. So a
new module cannot inherit "no gate" by being new. Only pull requests cancel a
superseded CI run; a `main` run always completes, because the promotion gate asks
whether one exact SHA has a successful `main` push run and a cancelled run cannot
answer that.
Every deployment still needs the production `/readyz` read-back. The `production` branch
is only a release pointer: its CI job does not repeat the full suite. Before Railway can
deploy, `scripts/verify_production_promotion.py` requires that the exact `production` SHA
is the current `main` head, has a successful `main` push run of the full CI workflow, and
builds a valid release identity. Railway's **Wait for CI** waits for this lightweight
production job; `/readyz` then proves the staged private deployment identity and running
release match after startup. A failed or stale main run blocks promotion rather than
falling back to a partial test result.
Do not infer a client acceptance, provider smoke, Scan Tools result, submission, or
deployment receipt from a green local test, a green PR, or a release bundle. Use the
change-gate output and the evidence boundary each gate names.
## Multi-agent runs
Task state lives in the GitHub issue. Never in conversation memory, and never in a
tracked state file: a file sits inside one worktree, so a parallel worker cannot read
another's copy until a merge, and two workers editing it conflict. Issue comments are
append-only, live outside every worktree, and any agent can read them with
`gh issue view <n> --comments`.
- **A worker reports by commenting on the issue.** What it delivered, which files it
touched, which verification it ran with the actual output, and what it could not
prove. A worker that reports only in its own terminal has not reported.
- **The coordinator owns the issue body.** It reads the delivered artifacts, not the
worker's account of them, and folds the outcome into an execution status block in the
body. Workers never edit the body.
- **One coordinator at a time**, named in that status block. It is whichever session
currently holds the role, not a fixed agent.
Two tasks may run in parallel only when both hold:
1. Neither depends on the other's output.
2. Their file ranges do not intersect. Every task brief states its file range before
dispatch; an unstated range means the task is not parallelizable yet.
A dependency chain runs sequentially. Splitting it across worktrees buys nothing and
costs a merge.
**The coordinator hands off after each task.** Update the issue body, then end the
session; the next task starts from a fresh session that reads the issue. A coordinator
that stays alive across a whole run carries every prior task in its context and pays for
it on every turn.
**A dispatched worker inherits the host CLI's model and reasoning effort** unless the
dispatch names them. Name them, and trust the launch receipt's effective values rather
than the arguments sent.
**Closing a task is three separate actions, and none triggers another:** release the
worker, remove its worktree, update the issue. Merging the pull request does none of
them. Decide a worktree is finished from its pull request state, not from
`git branch --merged`: this repository squash-merges, so a merged branch tip is never an
ancestor of `main`.
## Verification
Run:
```bash
python3 -m pip install --require-hashes -r requirements.lock
python3 -m unittest discover -s tests -p 'test_*.py'
python3 scripts/check_repo_safety.py
```
They run on Python 3.11 with `requirements.lock` installed -- one pinned package and what
it resolves to, hashes verified; see **Dependencies** above for why there is one at all.
They remain the merge and main confidence boundary; the production promotion job
intentionally proves reuse of that boundary for the same commit instead of executing it a
second time.