From 5ca5bc4bce6d149a6989c36f0e1ecc79f2beabf0 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:59:10 +0000 Subject: [PATCH 1/2] feat: integrate warden for automated code review Add warden.toml with code-simplifier (getsentry/skills) and find-bugs (getsentry/warden) triggers. Update validate skill to run warden -v after tests pass. Address warden findings: extract shared exercise parsing helper and add schema validation in updateTemplate. --- .claude/skills/validate.md | 33 ++++++++++++++++++++------------- src/commands/templates.ts | 37 +++++++++++++++---------------------- src/data/storage.ts | 2 +- warden.toml | 24 ++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 36 deletions(-) create mode 100644 warden.toml diff --git a/.claude/skills/validate.md b/.claude/skills/validate.md index 72dfe37..2dc7782 100644 --- a/.claude/skills/validate.md +++ b/.claude/skills/validate.md @@ -1,23 +1,30 @@ --- -description: Run full validation (lint, format, typecheck, tests, build). Use before committing or after changes. +description: Run full validation (lint, format, typecheck, tests, build) and warden code review. Use before committing or after changes. --- # Validate -Run the full validation suite to ensure code quality. +Run the full validation suite and code review to ensure code quality. -## Command +## Steps -```bash -bun run validate -``` +1. Run the validation suite: + ```bash + bun run validate + ``` + This runs: + - `oxlint` - Linting with type-aware rules + - `oxfmt --check` - Format verification + - `tsc --noEmit` - Type checking + - `vitest run` - Unit tests + - `tsc` - Build -This runs: -1. `oxlint` - Linting with type-aware rules -2. `oxfmt --check` - Format verification -3. `tsc --noEmit` - Type checking -4. `vitest run` - Unit tests -5. `tsc` - Build +2. If validation passes, run warden for code review feedback: + ```bash + warden -v + ``` + The `-v` flag streams findings in real-time (code simplification, bug detection). + Fix any issues warden finds before proceeding. ## When to Use @@ -27,4 +34,4 @@ This runs: ## Expected Output -All checks should pass with no errors. If any step fails, fix the issues before proceeding. +All checks should pass with no errors. Warden findings should be addressed before proceeding. diff --git a/src/commands/templates.ts b/src/commands/templates.ts index a8cec53..665d476 100644 --- a/src/commands/templates.ts +++ b/src/commands/templates.ts @@ -23,6 +23,10 @@ function parseExerciseSpec(spec: string): TemplateExercise { }; } +function parseExerciseSpecs(input: string): TemplateExercise[] { + return input.split(',').map((s) => parseExerciseSpec(s.trim())); +} + export function createTemplatesCommand(getProfile: () => string | undefined): Command { const templates = new Command('templates').description('Manage workout templates'); @@ -100,16 +104,12 @@ export function createTemplatesCommand(getProfile: () => string | undefined): Co const storage = getStorage(getProfile()); const id = options.id ?? slugify(name); - const exerciseSpecs = options.exercises.split(',').map((s) => s.trim()); - const exercises: TemplateExercise[] = []; - - for (const spec of exerciseSpecs) { - try { - exercises.push(parseExerciseSpec(spec)); - } catch (err) { - console.error((err as Error).message); - process.exit(1); - } + let exercises: TemplateExercise[]; + try { + exercises = parseExerciseSpecs(options.exercises); + } catch (err) { + console.error((err as Error).message); + process.exit(1); } const template = Template.parse({ @@ -166,19 +166,12 @@ export function createTemplatesCommand(getProfile: () => string | undefined): Co } if (options.exercises) { - const exerciseSpecs = options.exercises.split(',').map((s) => s.trim()); - const exercises: TemplateExercise[] = []; - - for (const spec of exerciseSpecs) { - try { - exercises.push(parseExerciseSpec(spec)); - } catch (err) { - console.error((err as Error).message); - process.exit(1); - } + try { + updates.exercises = parseExerciseSpecs(options.exercises); + } catch (err) { + console.error((err as Error).message); + process.exit(1); } - - updates.exercises = exercises; } if (Object.keys(updates).length === 0) { diff --git a/src/data/storage.ts b/src/data/storage.ts index b2c923a..b702e75 100644 --- a/src/data/storage.ts +++ b/src/data/storage.ts @@ -165,7 +165,7 @@ export class Storage { if (index === -1) { throw new Error(`Template "${id}" not found`); } - templates[index] = { ...templates[index]!, ...updates }; + templates[index] = Template.parse({ ...templates[index]!, ...updates }); this.saveTemplates(templates); } diff --git a/warden.toml b/warden.toml new file mode 100644 index 0000000..191921c --- /dev/null +++ b/warden.toml @@ -0,0 +1,24 @@ +version = 1 + +[defaults.filters] +ignorePaths = ["**/*.md", "**/*.lock", "**/*.json"] + +[[triggers]] +name = "code-simplifier" +event = "pull_request" +actions = ["opened", "synchronize", "reopened"] +skill = "code-simplifier" +remote = "getsentry/skills" + +[triggers.filters] +paths = ["src/**", "test/**"] + +[[triggers]] +name = "find-bugs" +event = "pull_request" +actions = ["opened", "synchronize", "reopened"] +skill = "find-bugs" +remote = "getsentry/warden" + +[triggers.filters] +paths = ["src/**"] From 8b80a4e8bc5500e7d7d2cfce3c9a5c401bffde60 Mon Sep 17 00:00:00 2001 From: Greg Pstrucha <875316+Gricha@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:32:24 +0000 Subject: [PATCH 2/2] feat: add per-side weight input tracking for volume calculations Dumbbell exercises now correctly report total weight moved by applying a 2x multiplier in volume calculations. PRs and E1RM remain based on input weight. Adds weightInput field to Exercise schema, tags 6 dumbbell exercises as per-side, and updates CLI commands. --- .claude/agents.md | 19 +++++++++ .claude/skills/create-pr.md | 6 ++- .claude/skills/validate.md | 2 +- src/commands/analytics.ts | 6 ++- src/commands/exercises.ts | 10 +++++ src/commands/session.ts | 13 +++--- src/data/storage.ts | 5 ++- src/exercises.ts | 10 ++++- src/types.ts | 5 +++ test/analytics.test.ts | 85 +++++++++++++++++++++++++++++++++++++ test/exercises.test.ts | 25 +++++++++++ 11 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 .claude/agents.md diff --git a/.claude/agents.md b/.claude/agents.md new file mode 100644 index 0000000..73102e6 --- /dev/null +++ b/.claude/agents.md @@ -0,0 +1,19 @@ +# Agent Workflow + +## Warden Code Review + +Running `npx @sentry/warden -v` is **required** once before creating a PR. Warden performs automated code review (simplification, bug detection) and its findings must be addressed before submitting. It only needs to be run once — do not re-run when creating the PR if it has already passed. + +### When to run + +- After all code changes are complete and `bun run validate` passes +- Before creating a pull request +- Only once per change set (no need to re-run after fixing warden findings unless substantial new code was added) + +### Workflow + +``` +make changes → bun run validate → npx @sentry/warden -v → fix findings → create PR +``` + +Skipping warden risks shipping code that warden will flag during PR review, creating unnecessary back-and-forth. Always run it locally first. diff --git a/.claude/skills/create-pr.md b/.claude/skills/create-pr.md index 4ee9f12..7aeb64b 100644 --- a/.claude/skills/create-pr.md +++ b/.claude/skills/create-pr.md @@ -20,12 +20,14 @@ Create a pull request for the current branch. bun run validate ``` -2. Push branch if needed: +2. Ensure warden has been run and findings addressed (see validate skill). Do not re-run if already done. + +3. Push branch if needed: ```bash git push -u origin HEAD ``` -3. Create PR with gh CLI: +4. Create PR with gh CLI: ```bash gh pr create --title "Brief title" --body "## Summary - Change 1 diff --git a/.claude/skills/validate.md b/.claude/skills/validate.md index 2dc7782..a979829 100644 --- a/.claude/skills/validate.md +++ b/.claude/skills/validate.md @@ -21,7 +21,7 @@ Run the full validation suite and code review to ensure code quality. 2. If validation passes, run warden for code review feedback: ```bash - warden -v + npx @sentry/warden -v ``` The `-v` flag streams findings in real-time (code simplification, bug detection). Fix any issues warden finds before proceeding. diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index c271fbf..ee63b65 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -189,9 +189,10 @@ export function createVolumeCommand(getProfile: () => string | undefined): Comma let exerciseSets = 0; let exerciseVol = 0; + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; for (const set of log.sets) { - const vol = set.weight * set.reps; + const vol = set.weight * set.reps * multiplier; totalSets++; totalVolume += vol; exerciseSets++; @@ -294,6 +295,7 @@ export function createProgressionCommand(getProfile: () => string | undefined): return; } + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; const progressionData = limited.map(({ workout, log }) => { const bestSet = log.sets.reduce((best, set) => { const e1rm = calculateE1RM(set.weight, set.reps); @@ -301,7 +303,7 @@ export function createProgressionCommand(getProfile: () => string | undefined): return e1rm > bestE1rm ? set : best; }, log.sets[0]!); - const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps, 0); + const totalVolume = log.sets.reduce((sum, s) => sum + s.weight * s.reps * multiplier, 0); const e1rm = calculateE1RM(bestSet.weight, bestSet.reps); return { diff --git a/src/commands/exercises.ts b/src/commands/exercises.ts index 3b32050..8e68ffd 100644 --- a/src/commands/exercises.ts +++ b/src/commands/exercises.ts @@ -6,6 +6,7 @@ import { type MuscleGroup, type ExerciseType, type Equipment, + type WeightInput, } from '../types.js'; export function createExercisesCommand(_getProfile: () => string | undefined): Command { @@ -68,6 +69,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C console.log(`ID: ${exercise.id}`); console.log(`Type: ${exercise.type}`); console.log(`Equipment: ${exercise.equipment}`); + console.log(`Weight input: ${exercise.weightInput}`); console.log(`Muscles: ${exercise.muscles.join(', ')}`); if (exercise.aliases.length > 0) { console.log(`Aliases: ${exercise.aliases.join(', ')}`); @@ -85,6 +87,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C .requiredOption('--equipment ', 'Equipment type') .option('--id ', 'Custom ID (defaults to slugified name)') .option('--aliases ', 'Comma-separated aliases') + .option('--weight-input ', 'Weight input type (total or per-side)', 'total') .option('--notes ', 'Exercise notes') .action( ( @@ -95,6 +98,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C equipment: string; id?: string; aliases?: string; + weightInput?: string; notes?: string; } ) => { @@ -110,6 +114,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C muscles, type: options.type as ExerciseType, equipment: options.equipment as Equipment, + weightInput: options.weightInput as WeightInput, notes: options.notes, }); @@ -132,6 +137,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C .option('--equipment ', 'New equipment') .option('--add-alias ', 'Add an alias') .option('--remove-alias ', 'Remove an alias') + .option('--weight-input ', 'Weight input type (total or per-side)') .option('--notes ', 'New notes') .action( ( @@ -141,6 +147,7 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C muscles?: string; type?: string; equipment?: string; + weightInput?: string; addAlias?: string; removeAlias?: string; notes?: string; @@ -168,6 +175,9 @@ export function createExercisesCommand(_getProfile: () => string | undefined): C if (options.equipment) { updates.equipment = options.equipment as Equipment; } + if (options.weightInput) { + updates.weightInput = options.weightInput as WeightInput; + } if (options.notes) { updates.notes = options.notes; } diff --git a/src/commands/session.ts b/src/commands/session.ts index 0ee0498..8326f2d 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -17,15 +17,16 @@ function calculateStats(workout: Workout, storage: ReturnType const musclesSet = new Set(); for (const exerciseLog of workout.exercises) { + const exercise = storage.getExercise(exerciseLog.exercise); + if (!exercise) continue; + totalSets += exerciseLog.sets.length; + const multiplier = exercise.weightInput === 'per-side' ? 2 : 1; for (const set of exerciseLog.sets) { - totalVolume += set.weight * set.reps; + totalVolume += set.weight * set.reps * multiplier; } - const exercise = storage.getExercise(exerciseLog.exercise); - if (exercise) { - for (const muscle of exercise.muscles) { - musclesSet.add(muscle); - } + for (const muscle of exercise.muscles) { + musclesSet.add(muscle); } } diff --git a/src/data/storage.ts b/src/data/storage.ts index b702e75..1056ccf 100644 --- a/src/data/storage.ts +++ b/src/data/storage.ts @@ -81,8 +81,9 @@ export class Storage { this.ensureDir(); const exercisesPath = this.exercisesPath(); if (!fs.existsSync(exercisesPath)) { - fs.writeFileSync(exercisesPath, JSON.stringify(defaultExercises, null, 2)); - return defaultExercises; + const parsed = defaultExercises.map((e) => Exercise.parse(e)); + fs.writeFileSync(exercisesPath, JSON.stringify(parsed, null, 2)); + return parsed; } const raw = JSON.parse(fs.readFileSync(exercisesPath, 'utf-8')); return raw.map((e: unknown) => Exercise.parse(e)); diff --git a/src/exercises.ts b/src/exercises.ts index f1f4ba3..0cf91c5 100644 --- a/src/exercises.ts +++ b/src/exercises.ts @@ -1,6 +1,6 @@ -import type { Exercise } from './types.js'; +import type { ExerciseInput } from './types.js'; -export const defaultExercises: Exercise[] = [ +export const defaultExercises: ExerciseInput[] = [ { id: 'bench-press', name: 'Bench Press', @@ -24,6 +24,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['chest', 'triceps', 'front-delts'], type: 'compound', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'overhead-press', @@ -40,6 +41,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['shoulders', 'triceps', 'front-delts'], type: 'compound', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'squat', @@ -120,6 +122,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['side-delts', 'shoulders'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'rear-delt-fly', @@ -128,6 +131,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['rear-delts', 'back'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'bicep-curl', @@ -136,6 +140,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['biceps'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'barbell-curl', @@ -152,6 +157,7 @@ export const defaultExercises: Exercise[] = [ muscles: ['biceps', 'forearms'], type: 'isolation', equipment: 'dumbbell', + weightInput: 'per-side', }, { id: 'tricep-pushdown', diff --git a/src/types.ts b/src/types.ts index 94314ef..1a12ec9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,9 @@ export const Equipment = z.enum([ ]); export type Equipment = z.infer; +export const WeightInput = z.enum(['total', 'per-side']); +export type WeightInput = z.infer; + export const Exercise = z.object({ id: z.string(), name: z.string(), @@ -42,9 +45,11 @@ export const Exercise = z.object({ muscles: z.array(MuscleGroup), type: ExerciseType, equipment: Equipment, + weightInput: WeightInput.default('total'), notes: z.string().optional(), }); export type Exercise = z.infer; +export type ExerciseInput = z.input; export const TemplateExercise = z.object({ exercise: z.string(), diff --git a/test/analytics.test.ts b/test/analytics.test.ts index ccef3ce..1091f65 100644 --- a/test/analytics.test.ts +++ b/test/analytics.test.ts @@ -323,3 +323,88 @@ describe('Progression tracking', () => { expect(parsed.progression).toHaveLength(1); }); }); + +describe('Per-side weight input', () => { + let testHome: string; + let storage: Storage; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'workout-test-')); + process.env.HOME = testHome; + resetStorage(); + createProfile('default'); + storage = getStorage('default'); + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + it('doubles volume for per-side exercises', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-arms`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('volume --week --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.totalVolume).toBe(500); + }); + + it('does not double volume for barbell exercises', () => { + const today = new Date().toISOString().split('T')[0]!; + storage.finishWorkout({ + id: `${today}-push`, + date: today, + template: null, + startTime: `${today}T10:00:00Z`, + endTime: `${today}T11:00:00Z`, + exercises: [{ exercise: 'bench-press', sets: [{ weight: 135, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('volume --week --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.totalVolume).toBe(1350); + }); + + it('PRs use input weight not doubled', () => { + storage.finishWorkout({ + id: '2026-01-20-arms', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 30, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('pr bicep-curl --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed[0].weight).toBe(30); + expect(parsed[0].e1rm).toBe(Math.round(30 * (1 + 10 / 30))); + }); + + it('progression volume is doubled for per-side exercises', () => { + storage.finishWorkout({ + id: '2026-01-20-arms', + date: '2026-01-20', + template: null, + startTime: '2026-01-20T10:00:00Z', + endTime: '2026-01-20T11:00:00Z', + exercises: [{ exercise: 'bicep-curl', sets: [{ weight: 25, reps: 10, rir: null }] }], + notes: [], + }); + + const { stdout } = cli('progression bicep-curl --json', testHome); + const parsed = JSON.parse(stdout); + expect(parsed.progression[0].totalVolume).toBe(500); + }); +}); diff --git a/test/exercises.test.ts b/test/exercises.test.ts index fa38ad2..a586491 100644 --- a/test/exercises.test.ts +++ b/test/exercises.test.ts @@ -36,6 +36,31 @@ describe('defaultExercises', () => { expect(uniqueIds.size).toBe(ids.length); }); + it('dumbbell exercises have per-side weightInput', () => { + const perSideIds = [ + 'dumbbell-bench-press', + 'dumbbell-shoulder-press', + 'lateral-raise', + 'rear-delt-fly', + 'bicep-curl', + 'hammer-curl', + ]; + for (const id of perSideIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.weightInput, `${id} should be per-side`).toBe('per-side'); + } + }); + + it('non-dumbbell exercises omit weightInput (defaults to total via schema)', () => { + const totalIds = ['bench-press', 'squat', 'deadlift', 'barbell-curl', 'leg-press']; + for (const id of totalIds) { + const exercise = defaultExercises.find((e) => e.id === id); + expect(exercise, `${id} should exist`).toBeDefined(); + expect(exercise?.weightInput, `${id} should not set weightInput`).toBeUndefined(); + } + }); + it('exercises have useful aliases', () => { const benchPress = defaultExercises.find((e) => e.id === 'bench-press'); expect(benchPress?.aliases).toContain('bench');