Skip to content
dithiothreitolPublic

About

Research-backed running coach in your terminal: training plan as code in a git repo, coach as a tool for your AI agent (CLI + MCP). No account, no backend.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

trainctl

New in 0.4.0: trainctl as a plugin for Claude Code and Codex, execution read from exported FIT, TCX and GPX files (no API key needed), and an engine that knows your time budget, the weeks after a race and plans without one:

trainctl init --agent claude      # profile + plugin setup for your agent
trainctl pull --from ~/exports    # what you ran, from watch or Strava export files
trainctl plan                     # regenerates the future, keeps what you decided
trainctl status                   # one screen: profile, plan, key, sync, proposals, git

plan on an existing plan keeps past and logged days, pins, hand edits and the volume baseline you approved. Every command takes --json, -C <dir> and relative dates such as tomorrow or sat. See the changelog.

CI License: MIT Node

A running coach that lives in your terminal. A plan-generating engine with a CLI and an MCP server: your training plan is a YAML file in a git repository, every session cites the research behind it, and your agent (Claude Code, Codex, any MCP client) gets the coach as a tool.

$ trainctl today
2026-08-04 · Tuesday
week 1/17 · base · 117 days to race
────────────────────────────
╭─ intervals · 8 km ───────────────────────────────────────────────────────────
│ 3 km warm-up + 4 × 1 km @ 4:32/km, 2 min walk recovery. 1 km cool-down jog
│ to finish.
╰──────────────────────────────────────────────────────────────────────────────
→ why this session: trainctl why --date 2026-08-04

No account, no database, no backend. Everything is files in the current directory — trainctl.yaml, plan/, log.jsonl — so the history of your training is your git history.

Polska wersja: README.pl.md.


Contents · Install · Quick start · The daily loop · Commands · Configuration · Plan as code · Agent (MCP) · Watch sync and export · Language · How it decides · Where it refuses · FAQ · Development


Install

npx trainctl init          # no install at all
# or
npm install -g trainctl    # then just: trainctl

Node ≥ 22.18 is the only requirement. The package ships compiled JavaScript next to its TypeScript sources and source maps, so a stack trace still points at readable code — Node refuses to strip types under node_modules, which is why 0.1.0 was replaced by 0.1.1.

Quick start

1 · Make a training directory and a profile.

mkdir my-training && cd my-training && git init
trainctl init              # interactive wizard
trainctl init --template   # or: write trainctl.yaml and edit it yourself

init also drops an AGENTS.md into the directory (coach instructions for your agent) and adds .env, .env.* and .trainctl-secret to .gitignore — the places an API key is allowed to live.

2 · Fill in the profile. The minimum is your recent volume, the days you can train, and one race result — zones come from races, never from a watch's threshold estimate:

athlete:
  recentWeeklyKm: 45          # average of the last ~4 weeks
  peakWeeklyKm: 65            # highest volume you have held (optional)
  daysAvailable: [tue, wed, thu, sat, sun]
  longRunDay: sat
  results:
    - { date: "2026-03-29", distanceKm: 10, timeSec: 2580, name: "Spring 10K" }
goal:
  name: "Half marathon"
  date: "2026-11-29"
  distanceKm: 21.0975
  priority: A

3 · Generate the plan.

$ trainctl plan
Half marathon · 21.0975 km
2026-11-29 · 17 weeks of plan
──────────────────────────────────
Volume peak                    52 km/week
Recommended for this distance  42 km/week
VDOT                           47.7 (from a race result)

╭─ Predicted finish ───────────────────────────────────────────────────────────
│ 1:32:24 – 1:38:07 (method: vdot)
│ Always a range, never a single number (W-1).
╰──────────────────────────────────────────────────────────────────────────────

Structure
• base: weeks 1–6
• build: weeks 7–12
• peak: weeks 13–15
• taper: weeks 16–16
• race week: weeks 17–17

✓ Saved plan/plan.yaml + plan/PLAN.md
→ trainctl today · trainctl week · trainctl why

plan/plan.yaml is the source of truth — machine-readable, hand-editable. plan/PLAN.md is the same plan rendered for humans, so GitHub shows a readable table on the repo page.

4 · Commit it.

git add -A && git commit -m "training plan: half marathon, 29 Nov"

From here the plan behaves like any other code: branches, diffs, review, CI.

The daily loop

$ trainctl week
Week 1/17 · from 2026-08-03
base (pyramidal) · target 49 km · planned 41 km
───────────────────────────────────
DAY  DATE   KM     WORKOUT
Mon  08-03  —      rest
Tue  08-04  8 km   3 km warm-up + 4 × 1 km @ 4:32/km, 2 min walk recovery. 1
                   km cool-down jog to finish.
Wed  08-05  —      rest
Thu  08-06  9 km   5 km easy + hills: 15 × 200 m (relaxed). 1 km cool-down jog
                   to finish.
Fri  08-07  —      rest
Sat  08-08  17 km  17 km very easy.
Sun  08-09  7 km   3 km warm-up + 3 km progression run (5:01 → 4:32/km). 1 km
                   cool-down jog to finish.

Ask why, before you trust it. Every session carries its purpose and the rule IDs behind it:

$ trainctl why --date 2026-08-04
Why this session · 2026-08-04
phase: base (pyramidal)
─────────────────────────────────────
╭─ intervals ──────────────────────────────────────────────────────────────────
│ Interval session — the stimulus follows the phase: around threshold
│ (pyramidal) or VO₂max (polarized).
╰──────────────────────────────────────────────────────────────────────────────

Rules
• I-1 — base/build: a pyramidal distribution — plenty of easy running, accents
  around threshold (Casado 2022; Knopp 2024)
• I-7 — ≥48 h between quality sessions — the hard day / easy day principle
  (Casado 2022)
• I-8 — two accents a week at ≥4 sessions, one at 3 (Casado 2022)
• P-1 — load undulates rather than climbing in a line — undulating progression
  gave +22% VO₂max vs +11% linear (RCT Costa 2019)

→ sources and parameters: docs/science/FOUNDATIONS.md §10

Record what actually happened.

trainctl log                    # in a terminal: pick the session, answer a few questions
trainctl log --km 8 --time 41:20 --rpe 4 --note "felt good"
trainctl log --date yesterday --status skipped --note "work"

Dates accept YYYY-MM-DD, today/tomorrow/yesterday, +N/-N and weekday names (thu). log, review, adapt and pull look back; everything else looks ahead.

--rpe is the session effort on the 1–10 scale. The engine stores it and the weekly review quotes it back verbatim — it never turns feelings into zones, paces or volume (that refusal is deliberate; see Where it refuses). The trail exists so that a conversation — with your coach-agent or with yourself in three weeks — starts from what you actually said at the time.

Renegotiate the week when life gets in the way. shift swaps two sessions; reschedule re-solves the whole week around days you cannot train, and says out loud what it sacrificed:

$ trainctl reschedule --block 2026-08-06 2026-08-08
Week renegotiation · from 2026-08-03
blocked: 2026-08-06, 2026-08-08
────────────────────────────────────────────
DAY  DATE   BEFORE     AFTER
Mon  08-03  —          —
Tue  08-04  intervals  intervals
Wed  08-05  —          long run
Thu  08-06  hills      —
Fri  08-07  —          —
Sat  08-08  long run   —
Sun  08-09  tempo      tempo

What changes
• long: 2026-08-08 → 2026-08-05
• dropped: easy_hills from 2026-08-06 — ran out of days — an easy session costs
  least (volume, not stimulus)
• trade-off: long run outside its preferred day (Wednesday)
⚠ We do not make up the dropped kilometres on the following days — piling volume
  on after a missed session works against the progression (P-1/P-3).

→ this is a preview; apply with: trainctl reschedule --apply (same --block)

Nothing is written until you pass --apply — and then it is a diff you can read before committing.

Once a week, take stock. trainctl review is the Monday ritual in one call: what you did, what it means, what is ahead (it pulls from intervals.icu first if a key is configured). trainctl adapt compares execution with the plan and proposes corrections; it never rewrites the plan behind your back.

Commands

command what it does key flags
init profile wizard --template, --from-intervals, --agent claude|codex|all, --commit
plan generate the plan; on an existing plan regenerate only the future --date, --fresh, --commit
today the session for today --date
week the week, with journal status --date, -i (arrow-key browsing)
why purpose + research rules behind a session --date
log record a completed session (no flags in a terminal: asks) --status, --km, --time, --test-time, --rpe, --note, --commit
shift swap two sessions inside a week --from, --to (or pick from a list), --commit
reschedule re-solve the week around busy days --block, --time, --pin, --preparation, --strategy, --apply, --commit
adapt analyse execution, propose corrections --date, --prepare, --apply <id>, --commit
shorten fit one session into the time you have, as a proposal --minutes, --date, --apply <id>, --commit
review pull + adapt + week in one call --days, --date, --prepare
desk training windows around office hours --heavy
push send planned sessions to intervals.icu --days, --from, --to
pull fetch activities and wellness, compare --days, --date, --from <export folder>
export .fit, .ics, printout, race pack --what, --date
diff what a regeneration would change --plan <file>
check lint the plan; fails the exit code --strict
status the directory at a glance: profile, plan, key, data, proposals, git
rules every engine rule with its source class, or one in full [id]
completion shell completion script bash, zsh, fish, pwsh

Every command takes --lang en|pl, --json (prints { code, data, output } — the same data the MCP tools return) and -C <dir> (or TRAINCTL_DIR) to work on a training directory elsewhere. trainctl help <command> prints the full flag list with examples. Exit codes: 0 success, 1 error or refusal, 2 usage error (unknown command or flag, a date that is not a date).

--commit commits exactly the files the command changed — never .env, .trainctl-secret or sync.json, and never other changes you have staged. TRAINCTL_TODAY=YYYY-MM-DD simulates the date for trying a scenario; every output then starts by saying so, and past-day protection follows the simulated date.

Configuration

trainctl.yaml is the whole configuration — one file, in your repository. trainctl init --template writes it with comments; every key it supports:

# language: pl              # interface and plan language: en | pl (default: en)
athlete:
  sex: unspecified          # male | female | unspecified
  recentWeeklyKm: 45        # average of the last ~4 weeks
  peakWeeklyKm: 65          # highest volume you have held (optional)
  daysAvailable: [tue, wed, thu, sat, sun]
  longRunDay: sat
  results:                  # zone calibration — from races, not from a watch
    - { date: "2026-03-29", distanceKm: 10, timeSec: 2580, name: "Spring 10K" }
  tuneUpRaces:              # races on the way: B = mini-taper, C = run through
    - { date: "2026-09-19", distanceKm: 10, name: "Autumn 10K", priority: B }
goal:
  name: "Half marathon"
  date: "2026-11-29"
  distanceKm: 21.0975
  priority: A
  targetTimeSec: 5700       # optional — plan will judge how realistic it is
desk:                       # for trainctl desk (optional)
  workStart: "09:00"
  workEnd: "17:00"
  lunchMinutes: 45
  prefer: evening           # morning | lunch | evening
strength:                   # optional strength track, 2×/week
  enabled: true
  days: [mon, fri]          # optional preference
time:                       # optional: the minutes you actually have, preparation included
  weeklyMinutes: 300        # volume levels off at what fits (E-2)
  weekdayMinutes: 60        # longer sessions are shortened, with the trade-off shown (E-1)
  weekendMinutes: 120
sync:
  files: ./activities       # optional: exported FIT/TCX/GPX instead of intervals.icu

No race to train for? Use a base plan instead of goal with a date and distance — no taper, no prediction, volume grows to your peak and levels off (P-11):

goal:
  kind: base
  until: "2027-03-01"

A few consequences worth knowing:

  • results drives everything. Without a race result there is no VDOT and no zones; with a targetTimeSec but no result, plan will use the goal to derive zones and say clearly that it needs recalibration.
  • tuneUpRaces are treated as races, not as workouts: mini-taper before a B race, an easy day before it, the long run the day after, and no extra accent in that week.
  • strength is a separate track, not a running session: it adds no kilometres, disappears during the taper, and never lands the day before a hard session. Two weeks of getting into it, then heavy sessions, and one maintenance session a week in the peak (F-18, F-4, F-12). Log it with trainctl log --strength done. The justification is running economy — not injury prevention; see Where it refuses.
  • The plan does not stop at the race. After a half marathon or longer, two transition weeks follow: about half the pre-race volume, easy only, first run on the third day after a marathon; then back to normal with one accent (R-8, from three races in the coaching corpus). A new goal starts its cycle after them.

The intervals.icu API key never goes in this file. Use TRAINCTL_INTERVALS_API_KEY, a .env file or .trainctl-secret; an explicit environment variable wins over a file.

Plan as code

The plan is a YAML file in a git repository — and that changes what a plan can do, not just where it is stored.

What-if branches. A scenario lives on a branch or in a copied directory: change the race date there, regenerate there, then compare before you decide.

git switch -c what-if-december
sed -i 's/2026-11-29/2026-12-20/' trainctl.yaml && trainctl plan
git switch main
git show what-if-december:plan/plan.yaml > /tmp/scenario.yaml
trainctl diff --plan /tmp/scenario.yaml
Differences: current plan → /tmp/scenario.yaml
──────────────────────────────────────────────────────────────────────────────
• goal: Half marathon, 21.1 km, 2026-11-29 → Half marathon, 21.1 km, 2026-12-20
• ~ 2026-11-01: quality_continuous — same volume, different segments
• ~ week 2026-11-23: total of the days 16 → 52 km
• ~ 2026-11-24: easy → quality_intervals
• ~ 2026-11-29: race → long
• + week 2026-11-30: new (52 km)
• + week 2026-12-14: new (26 km)

Three extra weeks of build, a taper that lands three weeks later, and the race day that is no longer a race — all before anything is decided.

CI on your own training. trainctl check lints the plan against the engine's invariants — 48 h between accents, taper shape, strength adjacency, ≥75% easy volume — plus the file's internal consistency, each finding with its rule ID:

$ trainctl check
✓ No issues: 17 weeks and 66 sessions hold every invariant.

$ trainctl check --strict     # in CI: warnings fail too, exit code 1
Plan lint
invariants and file integrity, checked against plan/plan.yaml
─────────────────

Rule deviations
⚠ 2026-08-04 → 2026-08-05: intervals and tempo less than 48 h apart [I-7]

0 errors, 1 warning
strict mode: warnings count as failures

Errors always fail the exit code; warnings only under --strict. A ready workflow: docs/examples/ci-check.md.

A human coach as reviewer. Week changes are diffs, so a coach can review them the way engineers review code — comment on a line, approve, merge. Example: docs/examples/coach-review.md.

Reproducibility. The same trainctl.yaml and the same engine version produce the same plan — in five years too. Nothing drifts under you and no backend can shut down; pin the version in your training repo and every session stays explainable: a rule ID in the plan, a commit in the history.

Agent (MCP)

The same engine as tools for an agent, so the interface becomes conversation: "what am I running today?", "release on Thursday — move the intervals", "how did last week go?"

Claude Code — as a plugin. The plugin brings the MCP tools, five coach skills (/trainctl:review, /trainctl:debrief, /trainctl:shorten, /trainctl:what-if and the coach persona) and two hooks: the coach instructions at session start in a training directory, and trainctl check after an edit of plan/plan.yaml.

/plugin marketplace add dithiothreitol/trainctl
/plugin install trainctl@trainctl

trainctl init --agent claude writes .claude/settings.json into the training directory, so the plugin is enabled there for anyone who clones it.

Codex reads AGENTS.md on its own. trainctl init --agent codex adds the MCP server in .codex/config.toml (loaded once you trust the project); or install the plugin with codex plugin marketplace add dithiothreitol/trainctl and /plugins. Use one of the two, not both. Details and limits: docs/integrations/agents.md.

Without the plugin — Claude Code:

claude mcp add trainctl --env TRAINCTL_DIR="/path/to/my-training" \
  -- npx -y trainctl-mcp

Any MCP client works — the generic form:

{
  "mcpServers": {
    "trainctl": {
      "command": "npx",
      "args": ["-y", "trainctl-mcp"],
      "env": { "TRAINCTL_DIR": "/path/to/my-training" }
    }
  }
}

The tools run over the same handlers the CLI uses, so the agent and your terminal can never disagree: trainctl_plan, trainctl_today, trainctl_week, trainctl_log, trainctl_shift, trainctl_why, trainctl_diff, trainctl_check, trainctl_init, trainctl_push, trainctl_pull, trainctl_adapt, trainctl_shorten, trainctl_desk, trainctl_reschedule, trainctl_export, trainctl_review, trainctl_status, trainctl_rule. The server also exposes plan/PLAN.md, trainctl.yaml, AGENTS.md and every rule as resources (trainctl://plan, trainctl://rules/I-7, …), and the weekly review and the post-run debrief as prompts.

The AGENTS.md written by trainctl init turns the agent into a coach rather than a command runner: ask before regenerating a plan, ask for context when a week was missed, correlate what it can see (calendar, tracker, on-call) with the training week — and never invent numbers.

Watch sync and export

Sync goes through intervals.icu as the hub, which forwards to Garmin, Coros and Wahoo:

export TRAINCTL_INTERVALS_API_KEY=...   # Settings → Developer Settings
trainctl push --days 14                 # plan → intervals.icu calendar → watch
trainctl pull --days 28                 # execution + wellness → compared with the plan

push only ever touches entries it created itself (they carry a trainctl- external id), so your own calendar entries stay yours. Races go in as race events (A/B/C) and strength sessions as separate entries; if intervals.icu refuses either form, push says so and the running sessions are pushed anyway.

No account? Use the files your watch already exports. pull --from reads FIT, TCX and GPX files (also .gz, as in a Strava or Garmin bulk export), so execution can be compared with the plan without intervals.icu — and without the Strava restriction below:

trainctl pull --from ./activities   # once
sync:
  files: ./activities               # in trainctl.yaml: review and pull read it

The files stay on your disk; init adds activities/ to .gitignore, because GPS tracks give away where you live. Details: docs/integrations/files.md.

Connect your watch to intervals.icu directly (Settings → Connections), not through Strava. Since December 2024 intervals.icu does not pass Strava-sourced activities through its API, so pull receives records with no distance and no type. trainctl says so explicitly instead of reporting "0 runs" — but it cannot work around it. Details: docs/integrations/intervalsicu.md §1.8.1.

Export covers the offline paths:

trainctl export --what plan       # .fit workouts → GARMIN/Workouts over a cable
trainctl export --what workout --date 2026-08-04
trainctl export --what calendar   # .ics → Google Calendar, Outlook, phone
trainctl export --what print      # A4 sheet with a tick box per session
trainctl export --what race       # race pack: splits, paper pace band, heat table

The FIT encoder is written here rather than pulled in as a dependency; the files are checked byte by byte in tests and were confirmed by uploading them to an independent parser.

Language

English by default, Polish as a full second language:

trainctl today --lang pl            # once
export TRAINCTL_LANG=pl             # for the session
trainctl init --lang pl             # writes `language: pl` into trainctl.yaml

Flag beats variable, variable beats the file. Polish is not a translation: session descriptions carry the corpus coach's voice, with proper declension and decimal commas, while English reads like a natively written plan. Everything a human or an agent sees is bilingual — command output, --help, plan/PLAN.md, the config template, AGENTS.md, rule explanations, MCP tool descriptions, FIT workout steps, the printout and the race pack.

How it decides

Two sources, deliberately kept apart.

Published research. Every engine rule carries an ID from docs/science/FOUNDATIONS.md (~60 sources), and trainctl why quotes them at you. Values with no source behind them are marked in the code as engineering choices rather than dressed up as science — and where the evidence is thin, why says so: strength work is justified by running economy, not injury prevention, because the only meta-analysis on runners came out non-significant.

A corpus of 50 real coaching plans (2020–2025, one coach, ~1300 days). It sets the house style — session shapes, warm-up and cool-down lengths, which days carry accents. It has also refuted assumptions more than once: the coach never scheduled a time trial in 1231 days, which changed how calibration works; and measuring long runs removed a solver penalty that was pushing plans away from what the coach actually does. The corpus contains personal data and is not distributed.

Where it refuses

Three things the engine will not compute, each for a documented reason:

  • Injury risk. The load metrics used for it — ACWR, the 10% rule, "+30% is dangerous" — do not hold up in the literature (FOUNDATIONS N-1…N-3). A number here would be invented, so there is none.
  • Readiness from HRV. The performance advantage comes out at SMD 0.20 with the interval crossing zero (FOUNDATIONS §8). Zones come from race results instead.
  • Its own plan, silently. adapt returns proposals; applying them means the reviewed adapt --prepare → adapt --apply <id> workflow for volume and calibration, with the exact changes shown before application.

It also refuses rather than guesses: below 4 active weeks of history it will not infer a profile, above 25 °C it will not predict a heat penalty, and with no race result it will not calibrate zones from watch readings.

FAQ

Is this a training plan generator or a coach? A generator with a coach's manners: it explains, warns and negotiates, but it never applies a change to your plan without you.

What happens to my plan when I run trainctl plan again? Only the future changes. Past and logged days, pinned days, hand edits, approved shortenings and days whose new session would not fit a saved time budget stay as they are; an approved volume baseline from adapt stays until you change athlete.recentWeeklyKm yourself. A different goal distance, or a new goal after the race, starts a new cycle and keeps the earlier weeks as history. trainctl diff shows exactly the same changes without saving; --fresh starts over (the old plan is in git).

Can I edit plan/plan.yaml by hand? Yes — that is the point of plan-as-code. Run trainctl check afterwards; it verifies the invariants the generator guarantees and tells you what a hand edit broke.

Do I need intervals.icu? No. Everything except push works without it: pull --from and review read exported activity files, and export covers watch, calendar and paper.

Does it support cycling, swimming, triathlon? No. The engine is built for running; the domain model was designed with other sports in mind, but nothing else is implemented.

Is my training data sent anywhere? No. One outbound host, intervals.icu, and only when you ask for it. No telemetry. See SECURITY.md.

Is this medical advice? No. It is a training plan, and it says where the evidence is thin instead of pretending it is not.

Development

pnpm install
pnpm check       # typecheck + tests — what CI runs

641 tests. Seven of them backtest the engine against the coaching corpus, which is not distributed; without it that suite reports itself as one skipped test rather than quietly disappearing.

Six packages: trainctl (CLI), trainctl-core (engine), trainctl-mcp, trainctl-export, trainctl-sync-intervalsicu, trainctl-sync-files; the agent plugin lives in plugin/. Development runs straight from TypeScript (Node's type stripping); pnpm build emits the dist/ that gets published.

Architecture, phases and the ADR table: SPEC.md. How to contribute and what a change to an engine rule needs to carry: CONTRIBUTING.md. Reporting a vulnerability: SECURITY.md.

Licence

MIT — see LICENSE.

About

Research-backed running coach in your terminal: training plan as code in a git repo, coach as a tool for your AI agent (CLI + MCP). No account, no backend.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages