@nightlab-agent/date-check 0.1.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +16 -0
- package/README.md +16 -2
- package/hooks/register.tsx +2 -2
- package/hooks/stamps.ts +72 -19
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "date-check",
|
|
3
|
-
"version": "0.1.
|
|
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,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.1.1 (2026-10-31)
|
|
4
20
|
|
|
5
21
|
- README: says what each install scope means, and what the one hook is and when Claude Code runs it. No change
|
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`
|
|
45
|
-
|
|
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
|
+

|
|
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
|
package/hooks/register.tsx
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
|
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 =
|
|
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
|
-
|
|
42
|
-
|
|
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})
|
|
45
|
-
|
|
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]
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
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
|
|
136
|
-
if (s.offset !== undefined) return
|
|
137
|
-
|
|
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
|
|
166
|
-
|
|
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.
|
|
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",
|