Every prompt carries your local date, weekday and the week ranges, and a reply whose dates miss the period you asked about gets flagged

A Claude Code mod that ends wrong dates. Every prompt quietly carries your local date, time and weekday, with yesterday, this week, last week, this month and last month worked out, so Claude never guesses them. When you ask about a period and the reply's dates miss it, a line under the reply says so.

On a weekend, "last week" means the work week that just ended. Claude is also told to keep city and timezone names out of titles, labels and file names unless you ask.
If your work depends on the time somewhere else, list those places per folder in ~/.claude/clock/markets.json, and their local times join the brief in that folder:
[{ "folder": "/shop-app", "markets": [
{ "label": "US", "place": "New York", "zone": "America/New_York" },
{ "label": "Czechia", "place": "Prague", "zone": "Europe/Prague" }
] }]
Claude Code 2.1.286 or later (mods). The line under a reply is drawn in the Claude desktop app and in the terminal.
In Claude Code:
/plugin marketplace add hacksurvivor/claude-mods
/plugin install clock@hacksurvivor
/reload-plugins
~/.claude/clock/markets.json.See PRIVACY.md.
Apache-2.0
hooks/register.tsx 87 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Flag } from '../types'
5import { MARKETS_FILE, anchorOf, brief, isUnder, marketsFor, mismatch, periodAsked, wallClock } from './time'
6import type { Market, Period } from './time'
7
8// Every prompt carries the person's local date, time and weekday, the week
9// ranges worked out once, and the local times of the markets a folder's work
10// depends on, from ~/.claude/clock/markets.json. A reply
11// to a prompt about a period (last week, yesterday, this month) whose dates all
12// miss that period gets a line under it saying so.
13
14const FLAGS_KEPT = 20
15
16const flags = atom({ plugin: 'clock', key: 'flags' } as const, [] as Flag[])
17
18// The period the prompt now running asked about, if any.
19let asked: Period | undefined
20
21// The markets for this session's folder; none without the file.
22async function markets($: EngineInterface): Promise<Market[]> {
23 try {
24 const text = await $.fs.read(`${(await $.env.get('HOME')) ?? ''}/${MARKETS_FILE}`)
25
26 return marketsFor(JSON.parse(text) as unknown, await $.session.cwd())
27 } catch {
28 return []
29 }
30}
31
32const zone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone
33
34export const register: Register = on => {
35 on('prompt.submit', async ($, e, next) => {
36 if (e.origin?.kind === 'plugin') {
37 return next(e)
38 }
39
40 asked = periodAsked(e.text) ?? (e.turnId === undefined ? undefined : asked)
41 const said = brief(await $.clock.now(), zone(), await markets($))
42
43 return next({ ...e, context: [...(e.context ?? []), said] })
44 })
45
46 on('turn.complete', async ($, e, next) => {
47 const done = await next(e)
48 const period = asked
49
50 if (e.agentId !== undefined || e.reason !== 'answer' || period === undefined) {
51 return done
52 }
53
54 asked = undefined
55 const today = wallClock(await $.clock.now(), zone()).day
56 const text = mismatch(period, e.answer, today)
57
58 if (text !== undefined) {
59 await update($, flags, list => [...list, { anchor: anchorOf(e.answer), text }].slice(-FLAGS_KEPT))
60 }
61
62 return done
63 })
64
65 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
66 const mine = (await read($, flags)).filter(flag => isUnder(flag.anchor, e.props.text))
67
68 if (mine.length === 0) {
69 return next(e)
70 }
71
72 const drawn = await next(e)
73 const { Box, Text } = $.ui.resolve(e)
74
75 return (
76 <Box flexDirection="column">
77 {drawn}
78 {mine.map((flag, at) => (
79 <Box key={`clock-${at}`} marginTop={1} marginLeft={2}>
80 <Text color="warning">{flag.text}</Text>
81 </Box>
82 ))}
83 </Box>
84 )
85 })
86}
87hooks/time.ts 262 lines1// Calendar days and the week ranges Clock hands Claude, in the person's own
2// timezone, and the check of a reply's dates against the period asked for.
3
4export type Day = { y: number; m: number; d: number }
5export type Range = { from: Day; to: Day }
6export type Period = 'today' | 'yesterday' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
7
8const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
9const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
10
11// A place whose local time a folder's work depends on, from ~/.claude/clock/markets.json.
12export type Market = { label: string; place: string; zone: string }
13export type MarketsByFolder = { folder: string; markets: Market[] }[]
14
15export const MARKETS_FILE = '.claude/clock/markets.json'
16
17// The markets for a folder: every entry whose folder the path contains.
18export function marketsFor(config: unknown, cwd: string): Market[] {
19 if (!Array.isArray(config)) {
20 return []
21 }
22
23 return (config as MarketsByFolder)
24 .filter(entry => typeof entry?.folder === 'string' && cwd.includes(entry.folder) && Array.isArray(entry.markets))
25 .flatMap(entry => entry.markets.filter(market => typeof market?.zone === 'string' && typeof market?.label === 'string'))
26}
27
28export const serial = (day: Day): number => Date.UTC(day.y, day.m - 1, day.d) / 86_400_000
29
30export function dayOf(n: number): Day {
31 const date = new Date(n * 86_400_000)
32
33 return { y: date.getUTCFullYear(), m: date.getUTCMonth() + 1, d: date.getUTCDate() }
34}
35
36// Monday 0 … Sunday 6.
37export const weekday = (day: Day): number => (new Date(serial(day) * 86_400_000).getUTCDay() + 6) % 7
38
39// The wall clock in a zone: its day, time and offset from UTC.
40export function wallClock(now: number, zone: string): { day: Day; hh: number; mm: number; offset: number } {
41 const parts = Object.fromEntries(
42 new Intl.DateTimeFormat('en-US', {
43 timeZone: zone,
44 year: 'numeric',
45 month: 'numeric',
46 day: 'numeric',
47 hour: 'numeric',
48 minute: 'numeric',
49 hourCycle: 'h23',
50 })
51 .formatToParts(new Date(now))
52 .map(part => [part.type, part.value]),
53 )
54 const day = { y: Number(parts.year), m: Number(parts.month), d: Number(parts.day) }
55 const hh = Number(parts.hour)
56 const mm = Number(parts.minute)
57 const asUtc = Date.UTC(day.y, day.m - 1, day.d, hh, mm)
58 const offset = Math.round((asUtc - Math.floor(now / 60_000) * 60_000) / 60_000)
59
60 return { day, hh, mm, offset }
61}
62
63const shift = (day: Day, days: number): Day => dayOf(serial(day) + days)
64
65export function rangeOf(period: Period, today: Day): Range {
66 const monday = shift(today, -weekday(today))
67
68 switch (period) {
69 case 'today':
70 return { from: today, to: today }
71 case 'yesterday':
72 return { from: shift(today, -1), to: shift(today, -1) }
73 case 'thisWeek':
74 return { from: monday, to: shift(monday, 6) }
75 // On a weekend, "last week" is the work week that just ended.
76 case 'lastWeek':
77 return weekday(today) >= 5 ? { from: monday, to: shift(monday, 4) } : { from: shift(monday, -7), to: shift(monday, -3) }
78 case 'thisMonth':
79 return { from: { ...today, d: 1 }, to: shift({ y: today.m === 12 ? today.y + 1 : today.y, m: (today.m % 12) + 1, d: 1 }, -1) }
80 case 'lastMonth': {
81 const first = { ...today, d: 1 }
82 const end = shift(first, -1)
83
84 return { from: { ...end, d: 1 }, to: end }
85 }
86 }
87}
88
89const short = (day: Day): string => `${MONTHS[day.m - 1]?.slice(0, 3)} ${day.d}`
90const dayName = (day: Day): string => WEEKDAYS[weekday(day)]?.slice(0, 3) ?? ''
91
92// "Sep 28 – Oct 2", "Aug 10–14", "Oct 4".
93export function showRange(range: Range): string {
94 if (serial(range.from) === serial(range.to)) {
95 return short(range.from)
96 }
97
98 if (range.from.m === range.to.m && range.from.y === range.to.y) {
99 return `${short(range.from)}–${range.to.d}`
100 }
101
102 return `${short(range.from)} – ${short(range.to)}`
103}
104
105const longDay = (day: Day): string => `${WEEKDAYS[weekday(day)]}, ${day.d} ${MONTHS[day.m - 1]} ${day.y}`
106const pad = (n: number): string => String(n).padStart(2, '0')
107
108function showOffset(minutes: number): string {
109 const sign = minutes < 0 ? '−' : '+'
110 const abs = Math.abs(minutes)
111
112 return `UTC${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
113}
114
115const withDays = (range: Range): string => `${dayName(range.from)} ${showRange(range)}${serial(range.from) === serial(range.to) ? '' : ` (${dayName(range.to)})`}, ${range.to.y}`
116
117// What rides beside each prompt.
118export function brief(now: number, zone: string, markets: Market[]): string {
119 const here = wallClock(now, zone)
120 const today = here.day
121 const calendarLastWeek = { from: shift(today, -weekday(today) - 7), to: shift(today, -weekday(today) - 1) }
122 const isWeekend = weekday(today) >= 5
123
124 const lines = [
125 "Clock, from the person's computer:",
126 `- Now: ${longDay(today)}, ${pad(here.hh)}:${pad(here.mm)} (${showOffset(here.offset)})`,
127 `- Yesterday: ${longDay(shift(today, -1))}`,
128 `- This week: ${withDays(rangeOf('thisWeek', today))}; work days ${showRange({ from: rangeOf('thisWeek', today).from, to: shift(rangeOf('thisWeek', today).from, 4) })}`,
129 isWeekend
130 ? `- "Last week", as the person means it on a weekend: ${withDays(rangeOf('lastWeek', today))}, the work week that just ended. The calendar week before it: ${showRange(calendarLastWeek)}`
131 : `- Last week: work days ${withDays(rangeOf('lastWeek', today))}; calendar week ${showRange(calendarLastWeek)}`,
132 `- This month: ${MONTHS[today.m - 1]} ${today.y}. Last month: ${MONTHS[rangeOf('lastMonth', today).from.m - 1]} ${rangeOf('lastMonth', today).from.y}`,
133 ]
134
135 if (markets.length > 0) {
136 const times = markets.map(market => {
137 const there = wallClock(now, market.zone)
138
139 return `${market.label}${market.place ? ` (${market.place})` : ''} ${dayName(there.day)} ${short(there.day)} ${pad(there.hh)}:${pad(there.mm)}`
140 })
141 lines.push(`- Markets now: ${times.join(' · ')}`)
142 }
143
144 lines.push(
145 'Take dates from here instead of working them out. Keep city and timezone names out of titles, labels, headings and file names unless the person asks for them.',
146 )
147
148 return lines.join('\n')
149}
150
151const ASKED: [Period, RegExp][] = [
152 ['lastWeek', /\b(last|previous|past) week\b|прошл[а-яё]* недел|за недел/i],
153 ['thisWeek', /\bthis week\b|эт[а-яё]* недел|текущ[а-яё]* недел/i],
154 ['lastMonth', /\b(last|previous|past) month\b|прошл[а-яё]* месяц/i],
155 ['thisMonth', /\bthis month\b|эт[а-яё]* месяц|текущ[а-яё]* месяц/i],
156 ['yesterday', /\byesterday\b|вчера/i],
157 ['today', /\btoday\b|сегодня/i],
158]
159
160export function periodAsked(text: string): Period | undefined {
161 return ASKED.find(([, pattern]) => pattern.test(text))?.[0]
162}
163
164const MONTH_PATTERN =
165 '(jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|январ[ья]|феврал[ья]|марта?|апрел[ья]|ма[йя]|июн[ья]|июл[ья]|августа?|сентябр[ья]|октябр[ья]|ноябр[ья]|декабр[ья])'
166const RU_MONTHS = ['янв', 'фев', 'мар', 'апр', 'ма', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']
167const EN_MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']
168
169function monthOf(word: string): number {
170 const w = word.toLowerCase()
171 const en = EN_MONTHS.findIndex(prefix => w.startsWith(prefix))
172
173 return en >= 0 ? en + 1 : RU_MONTHS.findIndex(prefix => w.startsWith(prefix)) + 1
174}
175
176// A month and day with no year: the year that puts it nearest today.
177function near(m: number, d: number, today: Day): Day {
178 const candidates = [today.y - 1, today.y, today.y + 1].map(y => ({ y, m, d }))
179
180 return candidates.reduce((best, day) => (Math.abs(serial(day) - serial(today)) < Math.abs(serial(best) - serial(today)) ? day : best))
181}
182
183const DASH = '\\s*(?:-|–|—|to|по|до)\\s*'
184
185// Every calendar date a reply names, ranges spread to both ends.
186export function datesIn(text: string, today: Day): Day[] {
187 const found: Day[] = []
188 const valid = (day: Day): boolean => day.m >= 1 && day.m <= 12 && day.d >= 1 && day.d <= 31
189 const add = (day: Day) => valid(day) && found.push(day)
190 let rest = text
191
192 const take = (pattern: RegExp, read: (match: RegExpExecArray) => void) => {
193 rest = rest.replace(pattern, (...args) => {
194 read(args.slice(0, -2) as unknown as RegExpExecArray)
195
196 return ' '
197 })
198 }
199
200 take(/\b(\d{4})-(\d{2})-(\d{2})\b/g, m => add({ y: Number(m[1]), m: Number(m[2]), d: Number(m[3]) }))
201 take(/\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b/g, m => add({ y: Number(m[3]), m: Number(m[2]), d: Number(m[1]) }))
202 // 28 Sep – 2 Oct; Sep 28 – Oct 2
203 take(new RegExp(`\\b(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])\\.?${DASH}(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => {
204 add(near(monthOf(m[2] ?? ''), Number(m[1]), today))
205 add(near(monthOf(m[4] ?? ''), Number(m[3]), today))
206 })
207 take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})${DASH}${MONTH_PATTERN}(?![a-zа-яё])\\.?\\s+(\\d{1,2})\\b`, 'gi'), m => {
208 add(near(monthOf(m[1] ?? ''), Number(m[2]), today))
209 add(near(monthOf(m[3] ?? ''), Number(m[4]), today))
210 })
211 // 10–14 August; Aug 10–14
212 take(new RegExp(`\\b(\\d{1,2})${DASH}(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => {
213 add(near(monthOf(m[3] ?? ''), Number(m[1]), today))
214 add(near(monthOf(m[3] ?? ''), Number(m[2]), today))
215 })
216 take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})${DASH}(\\d{1,2})\\b`, 'gi'), m => {
217 add(near(monthOf(m[1] ?? ''), Number(m[2]), today))
218 add(near(monthOf(m[1] ?? ''), Number(m[3]), today))
219 })
220 // 14 August; Aug 14
221 take(new RegExp(`\\b(\\d{1,2})\\s+${MONTH_PATTERN}(?![a-zа-яё])`, 'gi'), m => add(near(monthOf(m[2] ?? ''), Number(m[1]), today)))
222 take(new RegExp(`(?<![a-zа-яё])${MONTH_PATTERN}\\.?\\s+(\\d{1,2})\\b`, 'gi'), m => add(near(monthOf(m[1] ?? ''), Number(m[2]), today)))
223
224 return found
225}
226
227// The footnote for a reply whose dates all miss the period asked for, if any.
228export function mismatch(period: Period, answer: string, today: Day): string | undefined {
229 const dates = datesIn(answer, today)
230
231 if (dates.length === 0) {
232 return undefined
233 }
234
235 const expected = rangeOf(period, today)
236 // A week asked for counts its whole calendar week; a day or month is exact.
237 const isWeek = period === 'thisWeek' || period === 'lastWeek'
238 const from = isWeek ? serial(expected.from) - weekday(expected.from) : serial(expected.from)
239 const to = isWeek ? from + 6 : serial(expected.to)
240
241 if (dates.some(day => serial(day) >= from && serial(day) <= to)) {
242 return undefined
243 }
244
245 const sorted = [...dates].sort((a, b) => serial(a) - serial(b))
246 const said = showRange({ from: sorted[0] as Day, to: sorted.at(-1) as Day })
247 const name = { today: 'Today is', yesterday: 'Yesterday was', thisWeek: 'This week is', lastWeek: 'Last week was', thisMonth: 'This month is', lastMonth: 'Last month was' }[period]
248
249 return `${name} ${showRange(expected)}. This reply says ${said}`
250}
251
252export function anchorOf(text: string): string {
253 return text.trim().slice(-160)
254}
255
256// The last text block of a reply ends where the turn's answer ends.
257export function isUnder(anchor: string, blockText: string): boolean {
258 const block = anchorOf(blockText)
259
260 return block.length >= 12 && anchor.endsWith(block.slice(-Math.min(block.length, 80)))
261}
262types/index.d.ts 11 lines1// A reply whose dates missed the period asked for, and what to say under it.
2export type Flag = { anchor: string; text: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 clock: {
7 flags: Flag[]
8 }
9 }
10}
11