@nightlab-agent/date-check 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  "name": "date-check",
9
9
  "source": "./",
10
10
  "description": "Checks the dates and times Claude Code writes into files against the real clock before the write lands.",
11
- "version": "0.1.0",
11
+ "version": "0.1.2",
12
12
  "category": "productivity"
13
13
  }
14
14
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "date-check",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Checks the dates and times Claude Code writes into changelogs, logs, release notes and handoffs against the real clock before the write lands: a stamp in the future is refused with the real time, a stale one gets a note.",
5
5
  "homepage": "https://github.com/nightlab-agent/date-check#readme",
6
6
  "repository": "https://github.com/nightlab-agent/date-check",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-11-01)
4
+
5
+ - These zone names after a time are now read: UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT,
6
+ CET, CEST, BST, IST (`01:40 EST`, `06:26 UTC`); a time with any other name is taken as local
7
+ time. A name used in more than one place (`CST`, `IST`, `BST`) is judged by every place it
8
+ can mean. Before,
9
+ `01:40 EST` was taken as local time, so a stamp 14 minutes ahead in New York on the night the
10
+ clocks went back got only a note that it was old, not a refusal.
11
+ - The hour the clocks go back over: a local time in it (01:00 to 02:00 in New York on
12
+ 2026-11-01) can mean two moments, and is now refused only when both are ahead, and noted
13
+ only when both are old.
14
+ Before, a line written at the real time in that hour could get a note calling it an hour old.
15
+ - Messages give the real time with its UTC offset (`2026-11-01 01:26 local time, UTC-05:00`).
16
+ - Tests: a log entry written late, with an earlier time than the line above it, is judged against
17
+ the clock alone (a note when it is over 30 minutes old), and the newer line is not judged again.
18
+
19
+ ## 0.1.1 (2026-10-31)
20
+
21
+ - README: says what each install scope means, and what the one hook is and when Claude Code runs it. No change
22
+ to the code.
23
+
3
24
  ## 0.1.0 (2026-10-31)
4
25
 
5
26
  - First version: before a Write, an Edit, or a shell command that writes to a file, finds the date and time stamps
package/README.md CHANGED
@@ -41,8 +41,14 @@ screen.
41
41
  `.text`, `.rst`, `.adoc`, `.asciidoc`, `.org`, `.log`, and `CHANGELOG`, `HISTORY`, `NEWS`, `RELEASES`,
42
42
  `NOTES` with no extension). A date in code is data, not a stamp.
43
43
  - Dates are found as `YYYY-MM-DD`, with an optional time and zone
44
- (`2026-10-31 16:05`, `2026-10-31T20:05Z`). Other formats (`Oct 31, 2026`) are
45
- not checked yet.
44
+ (`2026-10-31 16:05`, `2026-10-31T20:05Z`, `2026-10-31 16:05 EST`); a bare `16:05`
45
+ or `16:05 EST` counts at the start of a line in a file named for the day
46
+ (`log/2026-10-31.md`). Other formats (`Oct 31, 2026`) are not checked yet.
47
+ - Zone names read after a time: UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT,
48
+ CET, CEST, BST, IST. A time with any other name, or none, is your local time.
49
+ A name used in more than one place (`CST`, `IST`, `BST`) is only called wrong if
50
+ it is wrong for every place it can mean. In the hour the clocks go back over,
51
+ when 01:30 happens twice, a local time is only called wrong if it is wrong both times.
46
52
  - It checks Write, Edit, and shell commands where `echo` or `printf` writes to
47
53
  a file with `>>`, `>` or `| tee`, or `cat` or `tee` writes a heredoc to one.
48
54
 
@@ -50,6 +56,14 @@ Settings (asked when you enable the plugin): minutes ahead allowed (2), minutes
50
56
  behind before a note (30), days behind before a note (1), notes on old stamps
51
57
  (on).
52
58
 
59
+ ![Claude Code refused a log line stamped 01:58 EST at 01:39 EST by date-check, with the real time and its UTC offset, and Claude asked whether the stamp was meant as EST or EDT](https://raw.githubusercontent.com/nightlab-agent/date-check/003f73177b78ef78659626e8cacbf06d1ab5753d/docs/preview-zone.png)
60
+
61
+ Seen in a Claude Code 2.1.312 terminal (120 columns) on 2026-11-01 at 01:39 EST,
62
+ the night New York's clocks went back. Asked to add the line
63
+ `- 01:58 EST deploy finished`, Claude's edit was refused with the real time and
64
+ its offset, and Claude asked whether the deploy had finished at 01:58 EDT, an
65
+ hour earlier.
66
+
53
67
  ## What it writes
54
68
 
55
69
  Nothing. It reads the file a Write or an Edit is about to change, to judge only the new
@@ -57,7 +71,8 @@ lines. Nothing leaves your machine through this mod: it makes no network calls.
57
71
 
58
72
  ## How it works
59
73
 
60
- - `tool.call` on Write, Edit and Bash: finds the stamps the call adds, compares
74
+ - One hook, `tool.call`, which Claude Code runs around each tool call (it can
75
+ stop the call first). On Write, Edit and Bash it finds the stamps the call adds, compares
61
76
  each with Claude Code's clock, and either refuses the call with the real time
62
77
  or passes it on and adds a note after its result. A failure of the mod itself
63
78
  never stops the call.
@@ -70,7 +85,9 @@ One line at the Claude Code prompt:
70
85
  /plugin install date-check --marketplace nightlab-agent/date-check
71
86
  ```
72
87
 
73
- Answer `y` to add the marketplace, then pick a scope. The same from a shell:
88
+ Answer `y` to add the marketplace, then pick a scope (`user`: every project
89
+ of yours; `project`: everyone who opens this repo; `local`: this repo, only
90
+ you; [scopes](https://code.claude.com/docs/en/discover-plugins#choose-an-install-scope)). The same from a shell:
74
91
 
75
92
  ```
76
93
  claude plugin install date-check --marketplace nightlab-agent/date-check
@@ -78,11 +78,11 @@ const quote = (v: Verdict) => `"${v.stamp.text}" (in: ${v.stamp.line})`
78
78
 
79
79
  export const refusal = (vs: Verdict[]) =>
80
80
  `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} in the future. ` +
81
- `It is ${vs[0]!.real} now (local time). Write the real date and time, or leave the stamp out. ` +
81
+ `It is ${vs[0]!.real} now. Write the real date and time, or leave the stamp out. ` +
82
82
  `If a future date is meant (a deadline, a planned release), write the same text again and it goes through.`
83
83
 
84
84
  export const staleNote = (vs: Verdict[]) =>
85
- `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} older than now (${vs[0]!.real}, local time). ` +
85
+ `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} older than now (${vs[0]!.real}). ` +
86
86
  `If ${vs.length === 1 ? 'it records' : 'they record'} something past, leave ${vs.length === 1 ? 'it' : 'them'}; if ${vs.length === 1 ? 'it was' : 'they were'} meant as now, correct ${vs.length === 1 ? 'it' : 'them'}.`
87
87
 
88
88
  export const register: Register = (on, options: Options = {}) => {
package/hooks/stamps.ts CHANGED
@@ -19,30 +19,52 @@ export type Stamp = {
19
19
  minutes?: number
20
20
  /** A UTC offset in minutes when it carries one (`Z` is 0). */
21
21
  offset?: number
22
+ /** A zone name written after the time (`EST`, `UTC`), when it carries one. */
23
+ zone?: string
22
24
  }
23
25
 
24
26
  export type Verdict = { stamp: Stamp; kind: 'future' | 'stale'; real: string }
25
27
 
26
28
  const DATE = String.raw`(\d{4})-(\d{2})-(\d{2})`
27
- const TIME = String.raw`(?:[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?\s?(Z|[+-]\d{2}:?\d{2})?)?`
29
+ const NAMED = String.raw`(?:(?:UTC|GMT)[+-]\d{1,2}(?::?\d{2})?(?![\d:])|(?:Z|UTC|GMT|CEST|CET|BST|IST|[ECMP][SD]T)(?![A-Za-z\d+-]))`
30
+ const ZONE = String.raw`([+-]\d{2}:?\d{2}|` + NAMED + ')'
31
+ const TIME = String.raw`(?:[T ](\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?\s?` + ZONE + String.raw`?)?`
28
32
  const STAMP = DATE + TIME
29
33
  const LABEL = String.raw`(?:date|dated|updated|last[ _-]?updated|last[ _-]?modified|modified|released|release[ _-]?date|generated|written|created|as of|timestamp)`
30
34
 
31
35
  const heading = new RegExp(String.raw`^\s{0,3}#{1,6}\s.*?(?<![\d-])` + STAMP + String.raw`(?![\d-])`)
32
36
  const field = new RegExp(String.raw`^\s{0,3}(?:[-*+]\s+|>\s*|\|\s*)?[*_]*` + LABEL + String.raw`\**\s*[:=]\s*\**["'\x60]?(?:on\s)?` + STAMP + String.raw`(?![\d-])`, 'i')
33
37
  const opening = new RegExp(String.raw`^\s*(?:[-*+]\s+|>\s*)?[\[(]?` + STAMP + String.raw`(?![\d-])`)
34
- const timeOpening = /^\s*(?:[-*+]\s+|>\s*)?[\[(]?(\d{2}):(\d{2})(?::\d{2})?(?![\d:])[\])]?(?:\s|$|[,;|-])/
38
+ const timeOpening = new RegExp(String.raw`^\s*(?:[-*+]\s+|>\s*)?[\[(]?(\d{2}):(\d{2})(?::\d{2})?(?![\d:])(?:\s?(` + NAMED + String.raw`))?[\])]?(?:\s|$|[,;|-])`)
39
+
40
+ /**
41
+ * The UTC offsets in minutes a zone name can mean. A name used for two zones (CST is
42
+ * Chicago's winter and China's) has both: a stamp is only called wrong when it is wrong
43
+ * under every reading.
44
+ */
45
+ const ZONES: Record<string, number[]> = {
46
+ UTC: [0], GMT: [0], EST: [-300], EDT: [-240], CST: [-360, 480], CDT: [-300], MST: [-420], MDT: [-360],
47
+ PST: [-480], PDT: [-420], CET: [60], CEST: [120], BST: [60, 360], IST: [330, 60, 120],
48
+ }
35
49
 
36
50
  const valid = (y: number, m: number, d: number) => {
37
51
  if (m < 1 || m > 12 || d < 1) return false
38
52
  return d <= new Date(Date.UTC(y, m, 0)).getUTCDate()
39
53
  }
40
54
 
41
- const offsetOf = (z: string | undefined) => {
42
- if (z === undefined) return undefined
55
+ /** A UTC offset in minutes (`Z`, `+02:00`, `UTC+2`, `GMT-03:30`); undefined when it is not a real one. */
56
+ const offsetOf = (z: string) => {
43
57
  if (z === 'Z') return 0
44
- const m = /^([+-])(\d{2}):?(\d{2})$/.exec(z)!
45
- return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3]))
58
+ const m = /^(?:UTC|GMT)?([+-])(\d{1,2}):?(\d{2})?$/.exec(z)
59
+ if (!m || Number(m[2]) > 14 || !['00', '15', '30', '45', undefined].includes(m[3])) return undefined
60
+ return (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3] ?? 0))
61
+ }
62
+
63
+ /** Daylight time in the United States: the second Sunday of March to the first Sunday of November. */
64
+ const usDaylight = (date: string) => {
65
+ const [y, m, d] = date.split('-').map(Number) as [number, number, number]
66
+ const sunday = (month: number, nth: number) => { const first = new Date(Date.UTC(y, month, 1)).getUTCDay(); return 1 + ((7 - first) % 7) + 7 * (nth - 1) }
67
+ return (m > 3 || (m === 3 && d >= sunday(2, 2))) && (m < 11 || (m === 11 && d < sunday(10, 1)))
46
68
  }
47
69
 
48
70
  const build = (m: RegExpExecArray, at: number, line: string, where: Stamp['where']): Stamp | undefined => {
@@ -53,7 +75,15 @@ const build = (m: RegExpExecArray, at: number, line: string, where: Stamp['where
53
75
  if (minutes !== undefined && (Number(hh) > 23 || Number(mm) > 59)) return undefined
54
76
  const date = `${m[at]}-${m[at + 1]}-${m[at + 2]}`
55
77
  const text = m[0].slice(m[0].indexOf(date)).replace(/[\])"'\x60*]+$/, '').trim()
56
- return { text, line: line.trim().slice(0, 120), where, date, ...(minutes !== undefined ? { minutes } : {}), ...(m[at + 5] !== undefined ? { offset: offsetOf(m[at + 5]) } : {}) }
78
+ return { text, line: line.trim().slice(0, 120), where, date, ...(minutes !== undefined ? { minutes } : {}), ...zoneOf(m[at + 5]) }
79
+ }
80
+
81
+ const zoneOf = (written: string | undefined) => {
82
+ if (written === undefined) return {}
83
+ const z = written.toUpperCase()
84
+ if (ZONES[z]) return { zone: z }
85
+ const offset = offsetOf(z)
86
+ return offset === undefined ? {} : { offset }
57
87
  }
58
88
 
59
89
  /** A `YYYY-MM-DD` date in a file's name (logs/2026-10-31.md), the day its bare line times belong to. */
@@ -72,7 +102,7 @@ const lineStamp = (line: string, fileDate?: string): Stamp | undefined => {
72
102
  if ((m = field.exec(line))) return build(m, 1, line, 'field')
73
103
  if ((m = opening.exec(line))) return build(m, 1, line, 'line-start')
74
104
  if (fileDate && (m = timeOpening.exec(line)) && Number(m[1]) < 24 && Number(m[2]) < 60)
75
- return { text: `${m[1]}:${m[2]}`, line: line.trim().slice(0, 120), where: 'line-start', date: fileDate, minutes: Number(m[1]) * 60 + Number(m[2]) }
105
+ return { text: `${m[1]}:${m[2]}${m[3] ? ` ${m[3]}` : ''}`, line: line.trim().slice(0, 120), where: 'line-start', date: fileDate, minutes: Number(m[1]) * 60 + Number(m[2]), ...zoneOf(m[3]) }
76
106
  return undefined
77
107
  }
78
108
 
@@ -123,18 +153,38 @@ export const dayOf = (ms: number, offset?: number) => {
123
153
  ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
124
154
  : `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
125
155
  }
126
- /** The local time now as a person writes it: `2026-10-31 16:42`. */
156
+ /**
157
+ * The local time now as a person writes it, with its offset so that the hour the clocks go back
158
+ * over is not read twice: `2026-10-31 16:42 local time, UTC-04:00`.
159
+ */
127
160
  export const nowText = (ms: number) => {
128
161
  const d = new Date(ms)
129
- return `${dayOf(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
162
+ const o = -d.getTimezoneOffset()
163
+ const zone = `UTC${o < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(o) / 60))}:${pad(Math.abs(o) % 60)}`
164
+ return `${dayOf(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())} local time, ${zone}`
130
165
  }
131
166
 
132
- /** The moment a stamp names, in ms: its zone when it carries one, the local zone when not. */
133
- const momentOf = (s: Stamp) => {
167
+ /** The local zone's UTC offset in minutes at a moment (New York in winter: -300). */
168
+ export type ZoneAt = (ms: number) => number
169
+ const localZone: ZoneAt = ms => -new Date(ms).getTimezoneOffset()
170
+
171
+ /**
172
+ * The moments a stamp can name, in ms: one for an offset; one per reading of a zone name; and
173
+ * for a local time, both when it falls in the hour the clocks go back over (01:30 happens twice
174
+ * that night in New York), one otherwise.
175
+ */
176
+ const momentsOf = (s: Stamp, zoneAt: ZoneAt) => {
134
177
  const [y, m, d] = s.date.split('-').map(Number) as [number, number, number]
135
- const min = s.minutes ?? 0
136
- if (s.offset !== undefined) return Date.UTC(y, m - 1, d, 0, min) - s.offset * 60000
137
- return new Date(y, m - 1, d, 0, min).getTime()
178
+ const wall = Date.UTC(y, m - 1, d, 0, s.minutes ?? 0)
179
+ if (s.offset !== undefined) return [wall - s.offset * 60000]
180
+ if (s.zone) {
181
+ // "EST" is often written for Eastern time all year: in summer it can also mean EDT.
182
+ const loose = /^[ECMP]ST$/.test(s.zone) && usDaylight(s.date) ? [ZONES[s.zone]![0]! + 60] : []
183
+ return [...ZONES[s.zone]!, ...loose].map(o => wall - o * 60000)
184
+ }
185
+ const offsets = [...new Set([zoneAt(wall - 12 * 3600000), zoneAt(wall + 12 * 3600000)])]
186
+ const fits = offsets.filter(o => zoneAt(wall - o * 60000) === o)
187
+ return (fits.length ? fits : offsets.slice(0, 1)).map(o => wall - o * 60000)
138
188
  }
139
189
 
140
190
  export type Limits = { aheadMinutes: number; staleMinutes: number; staleDays: number }
@@ -145,8 +195,10 @@ export const LIMITS: Limits = { aheadMinutes: 2, staleMinutes: 30, staleDays: 1
145
195
  * locally and in UTC (a UTC date written in the evening west of Greenwich is not wrong); stale
146
196
  * when more than `staleDays` before today. A time is in the future when more than
147
197
  * `aheadMinutes` ahead of now; stale when more than `staleMinutes` behind it on the same day.
198
+ * A time that can mean two moments is judged by both: future only when both are ahead, stale
199
+ * only when both are behind.
148
200
  */
149
- export const judge = (stamps: Stamp[], now: number, limits: Limits = LIMITS): Verdict[] => {
201
+ export const judge = (stamps: Stamp[], now: number, limits: Limits = LIMITS, zoneAt: ZoneAt = localZone): Verdict[] => {
150
202
  const real = nowText(now)
151
203
  const today = dayOf(now)
152
204
  const todayUtc = dayOf(now, 0)
@@ -162,10 +214,11 @@ export const judge = (stamps: Stamp[], now: number, limits: Limits = LIMITS): Ve
162
214
  }
163
215
  continue
164
216
  }
165
- const at = momentOf(s)
166
- if (at > now + limits.aheadMinutes * 60000) out.push({ stamp: s, kind: 'future', real })
217
+ const moments = momentsOf(s, zoneAt)
218
+ const at = Math.max(...moments)
219
+ if (Math.min(...moments) > now + limits.aheadMinutes * 60000) out.push({ stamp: s, kind: 'future', real })
167
220
  else if (at < now - limits.staleMinutes * 60000) {
168
- const days = s.offset === undefined ? s.date === today : dayOf(at) === today
221
+ const days = s.offset === undefined && !s.zone ? s.date === today : dayOf(at) === today
169
222
  if (days || (now - at) / 86400000 > limits.staleDays) out.push({ stamp: s, kind: 'stale', real })
170
223
  }
171
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlab-agent/date-check",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Claude Code mod: checks the dates and times Claude Code writes into changelogs, logs, release notes and handoffs against the real clock before the write lands; a stamp in the future is refused with the real time.",
5
5
  "keywords": [
6
6
  "claude-code",