diagnose_punches
Identifies issues in raw clock punches—duplicates, odd counts, round-number bias, off-shift punches—and reports longest/shortest gaps, returning a recommendation: usable, review, or reject.
Instructions
Triage raw clock punches before trusting them: count, sort, dedup, surface duplicates / odd-punch counts / round-number bias, report longest and shortest gaps, and (when an expected shift is provided) flag punches that fall outside it. Returns a recommendation: 'usable', 'review', or 'reject'.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| date | Yes | Duty date for the punches, YYYY-MM-DD. | |
| punches | Yes | ||
| expectedShift | No | Optional shift definition. When provided, the report flags punches outside [start - 4h, end + 4h] as off-shift. | |
| dedupeSeconds | No |
Implementation Reference
- src/tools/diagnose-punches.ts:14-87 (registration)The registerDiagnosePunches function registers the 'diagnose_punches' tool on the McpServer via server.tool(), defining its schema, description, and handler.
export function registerDiagnosePunches(server: McpServer): void { server.tool( 'diagnose_punches', "Triage raw clock punches before trusting them: count, sort, dedup, surface duplicates / odd-punch counts / round-number bias, report longest and shortest gaps, and (when an expected shift is provided) flag punches that fall outside it. Returns a recommendation: 'usable', 'review', or 'reject'.", { date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .describe('Duty date for the punches, YYYY-MM-DD.'), punches: z.array(PunchSchema), expectedShift: ShiftConfigSchema.optional().describe( 'Optional shift definition. When provided, the report flags punches outside [start - 4h, end + 4h] as off-shift.', ), dedupeSeconds: z.number().int().min(0).optional(), }, async ({ date, punches, expectedShift, dedupeSeconds }) => { const shift = expectedShift ?? { start: '00:00', end: '23:59' }; const result = resolveDay({ date, punches, shift, policy: { pairing: 'in-out-pairs', dedupeSeconds: dedupeSeconds ?? 60 }, }); const flagSet = new Set(result.flags); // Gap analysis from the resolved segments + interstitial gaps. const segs = result.segments; const gaps: number[] = []; for (let i = 0; i < segs.length - 1; i += 1) { const a = segs[i]!.out; const b = segs[i + 1]!.in; const minutes = Math.round((Date.parse(b) - Date.parse(a)) / 60_000); if (minutes > 0) gaps.push(minutes); } const longestGapMinutes = gaps.length === 0 ? 0 : Math.max(...gaps); const shortestGapMinutes = gaps.length === 0 ? 0 : Math.min(...gaps); const issues: string[] = []; if (flagSet.has('duplicate-punch')) issues.push('Duplicate punches were dropped — review your device dedupe window.'); if (flagSet.has('odd-punch-count')) issues.push('Odd punch count — at least one in or out is missing.'); if (flagSet.has('round-number-bias')) issues.push('Every punch lands on a 5-minute boundary with zero seconds — likely manual entry, not a device read.'); if (flagSet.has('no-punches')) issues.push('No usable punches.'); if (flagSet.has('missing-out-resolved')) issues.push("Engine synthesised a punch-out at shift end to recover — original record is incomplete."); const recommendation: 'usable' | 'review' | 'reject' = flagSet.has('no-punches') || flagSet.has('odd-punch-count') ? 'reject' : (flagSet.has('round-number-bias') || flagSet.has('duplicate-punch')) ? 'review' : 'usable'; return { content: [ jsonText({ date, count: punches.length, firstIn: result.firstIn, lastOut: result.lastOut, segments: result.segments.length, longestGapMinutes, shortestGapMinutes, flags: result.flags, issues, recommendation, workedMinutesEstimate: result.workedMinutes, spansMidnight: result.spansMidnight, }), ], }; }, ); } - src/tools/diagnose-punches.ts:29-85 (handler)The async handler function that executes the tool logic: calls resolveDay() from the core engine, performs gap analysis, checks flags for issues (duplicate, odd count, round-number bias, etc.), and returns a recommendation (usable/review/reject) with a detailed JSON report.
async ({ date, punches, expectedShift, dedupeSeconds }) => { const shift = expectedShift ?? { start: '00:00', end: '23:59' }; const result = resolveDay({ date, punches, shift, policy: { pairing: 'in-out-pairs', dedupeSeconds: dedupeSeconds ?? 60 }, }); const flagSet = new Set(result.flags); // Gap analysis from the resolved segments + interstitial gaps. const segs = result.segments; const gaps: number[] = []; for (let i = 0; i < segs.length - 1; i += 1) { const a = segs[i]!.out; const b = segs[i + 1]!.in; const minutes = Math.round((Date.parse(b) - Date.parse(a)) / 60_000); if (minutes > 0) gaps.push(minutes); } const longestGapMinutes = gaps.length === 0 ? 0 : Math.max(...gaps); const shortestGapMinutes = gaps.length === 0 ? 0 : Math.min(...gaps); const issues: string[] = []; if (flagSet.has('duplicate-punch')) issues.push('Duplicate punches were dropped — review your device dedupe window.'); if (flagSet.has('odd-punch-count')) issues.push('Odd punch count — at least one in or out is missing.'); if (flagSet.has('round-number-bias')) issues.push('Every punch lands on a 5-minute boundary with zero seconds — likely manual entry, not a device read.'); if (flagSet.has('no-punches')) issues.push('No usable punches.'); if (flagSet.has('missing-out-resolved')) issues.push("Engine synthesised a punch-out at shift end to recover — original record is incomplete."); const recommendation: 'usable' | 'review' | 'reject' = flagSet.has('no-punches') || flagSet.has('odd-punch-count') ? 'reject' : (flagSet.has('round-number-bias') || flagSet.has('duplicate-punch')) ? 'review' : 'usable'; return { content: [ jsonText({ date, count: punches.length, firstIn: result.firstIn, lastOut: result.lastOut, segments: result.segments.length, longestGapMinutes, shortestGapMinutes, flags: result.flags, issues, recommendation, workedMinutesEstimate: result.workedMinutes, spansMidnight: result.spansMidnight, }), ], }; }, - src/tools/diagnose-punches.ts:18-28 (schema)Input schema definition using Zod: date (YYYY-MM-DD), punches (array of PunchSchema), optional expectedShift (ShiftConfigSchema), and optional dedupeSeconds.
{ date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) .describe('Duty date for the punches, YYYY-MM-DD.'), punches: z.array(PunchSchema), expectedShift: ShiftConfigSchema.optional().describe( 'Optional shift definition. When provided, the report flags punches outside [start - 4h, end + 4h] as off-shift.', ), dedupeSeconds: z.number().int().min(0).optional(), }, - src/schemas.ts:11-17 (schema)PunchSchema used in the tool's input: at (ISO-8601 instant), optional source (biometric/mobile/manual/web), and optional location string.
export const PunchSchema = z.object({ at: z .string() .describe('ISO-8601 instant with an explicit offset, e.g. "2026-06-01T08:57:00+06:00".'), source: z.enum(['biometric', 'mobile', 'manual', 'web']).optional(), location: z.string().optional(), }); - src/schemas.ts:25-39 (schema)ShiftConfigSchema used for the optional expectedShift parameter: defines start, end, breaks, graceIn, graceOut, minHalfDayMinutes, and flexible fields.
export const ShiftConfigSchema = z.object({ start: z .string() .regex(/^\d{2}:\d{2}$/) .describe('Scheduled start, HH:MM, worksite local wall-clock.'), end: z .string() .regex(/^\d{2}:\d{2}$/) .describe('Scheduled end, HH:MM. If <= start, the shift is overnight and ends on the next day.'), breaks: z.array(BreakWindowSchema).optional(), graceIn: z.number().int().min(0).optional(), graceOut: z.number().int().min(0).optional(), minHalfDayMinutes: z.number().int().min(0).optional(), flexible: z.boolean().optional(), });