Skip to content

Commit e3976ac

Browse files
OneMuppetclaude
andcommitted
Add session audit script, wire audits into CI, replace OP-0029
- Add scripts/audit-sessions.mjs: 13 checks covering date gaps, team day rules, accessory frequency, time budgets, duration caps, and more - Add audit-metcons.mjs and audit-sessions.mjs to CI pipeline - Replace OP-0029 "Light Feather" (3 rds, 14 min cap, no pull) with "Dark Vine" (21-18-15-12-9-6-3 pull-ups/air squats/burpees, 20 min cap) - Update Mar 19 session warmup to prep for pull-heavy metcon - Raise warmup max from 10 to 12 min in specs (team days need longer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 86df2b2 commit e3976ac

6 files changed

Lines changed: 198 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,9 @@ jobs:
4949

5050
- name: Validate JSON files
5151
run: node scripts/validate-data.mjs
52+
53+
- name: Audit metcon scaling
54+
run: node scripts/audit-metcons.mjs
55+
56+
- name: Audit session integrity
57+
run: node scripts/audit-sessions.mjs

data/metcons.json

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3340,82 +3340,70 @@
33403340
},
33413341
{
33423342
"code": "OP-0029",
3343-
"name": "Light Feather",
3343+
"name": "Dark Vine",
33443344
"type": "for_time",
3345-
"timeCap": 14,
3346-
"rounds": 3,
3345+
"timeCap": 20,
3346+
"repScheme": [21, 18, 15, 12, 9, 6, 3],
33473347
"stimulus": {
3348-
"duration": "7-11 min",
3349-
"feel": "Bodyweight sprint. Push-ups and air squats should be unbroken. Burpees are the limiter. Running keeps the heart rate pegged. Fast and simple.",
3350-
"intent": "Bodyweight conditioning, high turnover, minimal equipment"
3348+
"duration": "11-15 min",
3349+
"feel": "Descending ladder. Each round gets shorter so the workout accelerates. Pull-ups are the limiter. Air squats and burpees should be unbroken throughout.",
3350+
"intent": "Gymnastics endurance, bodyweight conditioning, mental engagement through changing rep targets"
33513351
},
3352-
"coachNotes": "No barbell, no excuses. Push-ups and air squats unbroken every round. Burpees steady pace, just keep moving. Run at 80% effort. Should feel like a sprint, not a grind.",
3352+
"coachNotes": "The descending reps reward staying composed. Pull-ups will be the bottleneck for most. Break early in the 21s and 18s (sets of 7-9), then try to go unbroken from the 12s onward. Air squats and burpees should never break. The last three rounds are a sprint.",
33533353
"movements": [
33543354
{
3355-
"movement": "Push-up",
3356-
"reps": 15,
3355+
"movement": "Pull-up",
33573356
"scaling": {
33583357
"advanced_plus": {
3359-
"reps": 15
3358+
"sub": "Pull-up"
33603359
},
33613360
"advanced": {
3362-
"reps": 15
3361+
"sub": "Pull-up"
33633362
},
33643363
"intermediate_plus": {
3365-
"reps": 12
3364+
"sub": "Jumping Pull-up"
33663365
},
33673366
"intermediate": {
3368-
"reps": 10
3367+
"sub": "Jumping Pull-up"
33693368
},
33703369
"beginner_plus": {
3371-
"sub": "Knee Push-up",
3372-
"reps": 10
3370+
"sub": "Ring Row"
33733371
},
33743372
"beginner": {
3375-
"sub": "Box Push-up",
3376-
"reps": 8
3373+
"sub": "Ring Row"
33773374
}
33783375
}
33793376
},
33803377
{
3381-
"movement": "Air Squat",
3382-
"reps": 20
3378+
"movement": "Air Squat"
33833379
},
33843380
{
33853381
"movement": "Burpee",
3386-
"reps": 10,
33873382
"scaling": {
33883383
"advanced_plus": {
3389-
"reps": 10
3384+
"sub": "Burpee"
33903385
},
33913386
"advanced": {
3392-
"reps": 10
3387+
"sub": "Burpee"
33933388
},
33943389
"intermediate_plus": {
3395-
"reps": 10
3390+
"sub": "Burpee"
33963391
},
33973392
"intermediate": {
3398-
"reps": 8
3393+
"sub": "Burpee"
33993394
},
34003395
"beginner_plus": {
3401-
"sub": "Bodybuilder",
3402-
"reps": 8
3396+
"sub": "Bodybuilder"
34033397
},
34043398
"beginner": {
3405-
"sub": "Bodybuilder",
3406-
"reps": 6
3399+
"sub": "Bodybuilder"
34073400
}
34083401
}
3409-
},
3410-
{
3411-
"movement": "Run",
3412-
"distance": 200,
3413-
"unit": "m"
34143402
}
34153403
],
34163404
"goal": {
3417-
"target": "Finish in 7-11 min",
3418-
"scaleDown": "If you hit the 14 min cap, the bodyweight volume is too demanding. Drop one level."
3405+
"target": "Finish in 11-15 min",
3406+
"scaleDown": "If you hit the 20 min cap, the pull-ups are limiting you. Drop one level for a faster sub."
34193407
}
34203408
},
34213409
{

data/sessions.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,9 @@
416416
{
417417
"date": "2026-03-19",
418418
"title": "Thursday - Gymnastics + Conditioning",
419-
"estimatedMinutes": 42,
419+
"estimatedMinutes": 48,
420420
"warmup": {
421-
"notes": "3 rounds:\n10 push-ups\n10 air squats\n10 PVC pass-throughs\n30s plank hold",
421+
"notes": "3 rounds:\n5 scap pull-ups\n10 ring rows\n10 air squats\n10 PVC pass-throughs",
422422
"durationMinutes": 12
423423
},
424424
"strength": {
@@ -428,7 +428,7 @@
428428
"movement": "Rope Climb / Pull-up Progression",
429429
"scheme": "Skill: 14 min",
430430
"prescription": "Work on rope climbs or strict pull-up progressions",
431-
"notes": "Rope climbs for advanced. Strict pull-up sets for intermediates. Ring row negatives for beginners."
431+
"notes": "Rope climbs for advanced athletes. Strict pull-up sets of 3-5 for intermediates. Ring row negatives for beginners. Save the kipping for the metcon."
432432
}
433433
]
434434
},

scripts/audit-sessions.mjs

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { readFileSync } from "fs"
2+
import { join, dirname } from "path"
3+
import { fileURLToPath } from "url"
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url))
6+
const s = JSON.parse(readFileSync(join(__dirname, "..", "data", "sessions.json"), "utf8"))
7+
const m = JSON.parse(readFileSync(join(__dirname, "..", "data", "metcons.json"), "utf8"))
8+
9+
const metconsByCode = new Map(m.metcons.map(mc => [mc.code, mc]))
10+
let issues = []
11+
12+
// === 1. No duplicate dates ===
13+
const dateCounts = new Map()
14+
for (const sess of s.sessions) {
15+
dateCounts.set(sess.date, (dateCounts.get(sess.date) || 0) + 1)
16+
}
17+
for (const [date, count] of dateCounts) {
18+
if (count > 1) issues.push(`Duplicate date: ${date} appears ${count} times`)
19+
}
20+
21+
// === 2. No gaps in date range ===
22+
const dates = s.sessions.map(x => x.date).sort()
23+
if (dates.length > 0) {
24+
const start = new Date(dates[0])
25+
const end = new Date(dates[dates.length - 1])
26+
const dateSet = new Set(dates)
27+
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
28+
const iso = d.toISOString().slice(0, 10)
29+
if (!dateSet.has(iso)) issues.push(`Missing date: ${iso} (gap in coverage)`)
30+
}
31+
}
32+
33+
// === 3. Metcon code references exist ===
34+
for (const sess of s.sessions) {
35+
if (sess.metcon && !metconsByCode.has(sess.metcon)) {
36+
issues.push(`${sess.date}: metcon ${sess.metcon} not found in metcons.json`)
37+
}
38+
}
39+
40+
// === 4. No orphaned metcons (warning only) ===
41+
const usedCodes = new Set(s.sessions.map(x => x.metcon).filter(Boolean))
42+
const orphaned = m.metcons.filter(mc => !usedCodes.has(mc.code))
43+
if (orphaned.length > 0) {
44+
console.log(`INFO: ${orphaned.length} metcon(s) not referenced by any session: ${orphaned.map(x => x.code).join(", ")}`)
45+
}
46+
47+
// === 5. Tuesday + Saturday must be team metcons ===
48+
for (const sess of s.sessions) {
49+
if (!sess.metcon) continue
50+
const dow = new Date(sess.date).getDay() // 0=Sun, 2=Tue, 6=Sat
51+
const mc = metconsByCode.get(sess.metcon)
52+
if (!mc) continue
53+
54+
const isTeamDay = dow === 2 || dow === 6
55+
const isTeamMetcon = !!mc.team
56+
57+
if (isTeamDay && !isTeamMetcon) {
58+
issues.push(`${sess.date}: team day (Tue/Sat) but ${sess.metcon} is not a team metcon`)
59+
}
60+
if (!isTeamDay && isTeamMetcon) {
61+
issues.push(`${sess.date}: non-team day but ${sess.metcon} is a team metcon`)
62+
}
63+
}
64+
65+
// === 6. Accessory only on Mon/Wed/Fri, and not when metcon TC > 20 ===
66+
for (const sess of s.sessions) {
67+
const dow = new Date(sess.date).getDay() // 0=Sun, 1=Mon, ..., 6=Sat
68+
const hasAccessory = sess.accessory !== null && sess.accessory !== undefined
69+
const accessoryDays = [1, 3, 5] // Mon, Wed, Fri
70+
71+
if (hasAccessory && !accessoryDays.includes(dow)) {
72+
issues.push(`${sess.date}: has accessory but is not Mon/Wed/Fri`)
73+
}
74+
75+
if (hasAccessory && sess.metcon) {
76+
const mc = metconsByCode.get(sess.metcon)
77+
if (mc && mc.timeCap > 20) {
78+
issues.push(`${sess.date}: has accessory but metcon ${sess.metcon} timeCap is ${mc.timeCap} (>20 min)`)
79+
}
80+
}
81+
}
82+
83+
// === 7. Time budget: estimatedMinutes must equal sum of parts ===
84+
for (const sess of s.sessions) {
85+
const w = sess.warmup?.durationMinutes || 0
86+
const st = sess.strength?.durationMinutes || 0
87+
const mc = sess.metcon ? metconsByCode.get(sess.metcon) : null
88+
const tc = mc ? mc.timeCap : 0
89+
const a = sess.accessory?.durationMinutes || 0
90+
const sum = w + st + tc + a
91+
if (sum !== sess.estimatedMinutes) {
92+
issues.push(`${sess.date}: estimatedMinutes is ${sess.estimatedMinutes} but parts sum to ${sum} (warmup:${w} + strength:${st} + metcon:${tc} + accessory:${a})`)
93+
}
94+
}
95+
96+
// === 8. Session duration constraints ===
97+
for (const sess of s.sessions) {
98+
if (sess.estimatedMinutes > 60) {
99+
issues.push(`${sess.date}: estimatedMinutes is ${sess.estimatedMinutes} (max 60)`)
100+
}
101+
if (sess.warmup && sess.warmup.durationMinutes > 12) {
102+
issues.push(`${sess.date}: warmup is ${sess.warmup.durationMinutes} min (max 12)`)
103+
}
104+
if (sess.strength && sess.strength.durationMinutes > 20) {
105+
issues.push(`${sess.date}: strength is ${sess.strength.durationMinutes} min (max 20)`)
106+
}
107+
if (sess.accessory && sess.accessory.durationMinutes > 15) {
108+
issues.push(`${sess.date}: accessory is ${sess.accessory.durationMinutes} min (max 15)`)
109+
}
110+
}
111+
112+
// === 9. Strength block single-focus rule ===
113+
for (const sess of s.sessions) {
114+
if (sess.strength && sess.strength.movements && sess.strength.movements.length > 1) {
115+
issues.push(`${sess.date}: strength block has ${sess.strength.movements.length} movements (max 1)`)
116+
}
117+
}
118+
119+
// === 10. Accessory max 3 exercises (heuristic: count \n-separated lines) ===
120+
for (const sess of s.sessions) {
121+
if (sess.accessory && sess.accessory.notes) {
122+
const lines = sess.accessory.notes.split("\n").filter(l => l.trim().length > 0)
123+
if (lines.length > 3) {
124+
issues.push(`${sess.date}: accessory has ${lines.length} exercises (max 3)`)
125+
}
126+
}
127+
}
128+
129+
// === 11. Long metcon + strength constraint ===
130+
for (const sess of s.sessions) {
131+
if (!sess.metcon || !sess.strength) continue
132+
const mc = metconsByCode.get(sess.metcon)
133+
if (!mc) continue
134+
if (mc.timeCap >= 40) {
135+
issues.push(`${sess.date}: metcon ${sess.metcon} timeCap is ${mc.timeCap} but strength is not null`)
136+
}
137+
}
138+
139+
// === 12. No duplicate metcon usage on same date ===
140+
// (already covered by duplicate dates, but check for duplicate codes across different dates)
141+
const codeUsage = new Map()
142+
for (const sess of s.sessions) {
143+
if (!sess.metcon) continue
144+
if (!codeUsage.has(sess.metcon)) codeUsage.set(sess.metcon, [])
145+
codeUsage.get(sess.metcon).push(sess.date)
146+
}
147+
148+
// === 13. Required session fields ===
149+
for (const sess of s.sessions) {
150+
if (!sess.date) issues.push("Session missing date")
151+
if (!sess.title) issues.push(`${sess.date || "?"}: missing title`)
152+
if (sess.estimatedMinutes === undefined) issues.push(`${sess.date}: missing estimatedMinutes`)
153+
}
154+
155+
// === Summary ===
156+
console.log(`Audited ${s.sessions.length} sessions (${dates[0]} to ${dates[dates.length - 1]})`)
157+
console.log(`Metcon library: ${m.metcons.length} metcons, ${usedCodes.size} used in sessions`)
158+
if (issues.length === 0) {
159+
console.log("ALL CHECKS PASSED")
160+
} else {
161+
console.log(`\n${issues.length} ISSUES:`)
162+
issues.forEach(i => console.log(` ${i}`))
163+
process.exit(1)
164+
}

spec/programming.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -783,7 +783,7 @@ This ensures strict accountability. No hidden transition buffers or unaccounted
783783

784784
| Part | Max Duration | Notes |
785785
|------|-------------|-------|
786-
| Warmup | 10 min | General preparation, movement prep |
786+
| Warmup | 12 min | General preparation, movement prep (10 min typical, 12 for team days) |
787787
| Strength / Skill | 20 min | Includes rest periods between sets |
788788
| Metcon | 40 min | If metcon is 40 min, strength must be null |
789789
| Accessory | 15 min | Cool-down and supplementary work. Maximum 3 exercises. |

spec/wod-generation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ estimatedMinutes = warmup + strength + metcon.timeCap + accessory
210210

211211
| Part | Max | Typical |
212212
|------|-----|---------|
213-
| Warmup | 10 min | 8-10 |
213+
| Warmup | 12 min | 8-10 (up to 12 on team days) |
214214
| Strength | 20 min | 10-18 |
215215
| Metcon | 40 min | 8-30 |
216216
| Accessory | 15 min | 5-12 (max 3 exercises) |

0 commit comments

Comments
 (0)