@nightlab-agent/date-check 0.1.1 → 0.1.3

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.1",
11
+ "version": "0.1.3",
12
12
  "category": "productivity"
13
13
  }
14
14
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "date-check",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",
@@ -31,7 +31,7 @@
31
31
  "staleDays": {
32
32
  "type": "number",
33
33
  "title": "Days behind before a note",
34
- "description": "A written date more than this many days before today gets a note.",
34
+ "description": "A written date more than this many days before today gets a note; a new file named for such a day is refused once.",
35
35
  "default": 1
36
36
  },
37
37
  "staleNotes": {
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.3 (2026-11-01)
4
+
5
+ - A new Markdown or text file whose name carries a date more than a day before today
6
+ (`briefs/daily-brief-2025-06-12.md` created on 2026-11-01) is refused once, with the real
7
+ date. If that date is meant (a record of that day), Claude writes the same file again and it
8
+ goes through. A name with today's date, yesterday's or a later day's (a plan) is not judged,
9
+ nor a file that already exists, nor a date in a folder's name. The days allowed follow the
10
+ "days behind" setting. Before, only the stamps inside a file were checked, never its name.
11
+
12
+ ## 0.1.2 (2026-11-01)
13
+
14
+ - These zone names after a time are now read: UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT,
15
+ CET, CEST, BST, IST (`01:40 EST`, `06:26 UTC`); a time with any other name is taken as local
16
+ time. A name used in more than one place (`CST`, `IST`, `BST`) is judged by every place it
17
+ can mean. Before,
18
+ `01:40 EST` was taken as local time, so a stamp 14 minutes ahead in New York on the night the
19
+ clocks went back got only a note that it was old, not a refusal.
20
+ - The hour the clocks go back over: a local time in it (01:00 to 02:00 in New York on
21
+ 2026-11-01) can mean two moments, and is now refused only when both are ahead, and noted
22
+ only when both are old.
23
+ Before, a line written at the real time in that hour could get a note calling it an hour old.
24
+ - Messages give the real time with its UTC offset (`2026-11-01 01:26 local time, UTC-05:00`).
25
+ - Tests: a log entry written late, with an earlier time than the line above it, is judged against
26
+ the clock alone (a note when it is over 30 minutes old), and the newer line is not judged again.
27
+
3
28
  ## 0.1.1 (2026-10-31)
4
29
 
5
30
  - 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
@@ -17,9 +17,16 @@ Seen in a Claude Code 2.1.312 terminal (120 columns) on 2026-10-31 at 16:52.
17
17
  Asked to add a log line starting with 23:40, Claude's first write was refused
18
18
  with the real time. Because the request had named 23:40, Claude wrote the same
19
19
  line again, it went through, and Claude told the person it was later than now.
20
- The same happened with a changelog entry dated 2026-11-03. A stamp Claude
21
- guesses on its own, and the note on an old stamp, have not yet been seen on a
22
- screen.
20
+ The same happened with a changelog entry dated 2026-11-03.
21
+
22
+ ![Claude Code wrote a log line stamped 00:40 at 02:11; date-check's note on the old stamp gave Claude the real time, and Claude used 02:11 for a line it had held back](https://raw.githubusercontent.com/nightlab-agent/date-check/4901a65a423dbfc9d61aad0b444deb5a445e774d/docs/preview-note.png)
23
+
24
+ Seen on 2026-11-01 at 02:11 EST, same terminal. Asked to start a log line with
25
+ the current time and not to run commands, Claude did not guess: it asked for
26
+ the time. Then asked to add `- 00:40 backup started`, it wrote that line; the
27
+ note on the old stamp gave it the real time, and it wrote the line it had held
28
+ back as `- 02:11 release went out`. A stamp Claude guesses on its own has not
29
+ yet been seen on a screen.
23
30
 
24
31
  - A stamp more than 2 minutes in the future is refused, and the refusal tells
25
32
  Claude the real date and time. If the future date is meant (a deadline, a
@@ -27,6 +34,11 @@ screen.
27
34
  - A time more than 30 minutes old today, or a date more than a day old, goes
28
35
  through with a note to Claude: fine for a record of the past, a fix for one
29
36
  meant as now.
37
+ - A new Markdown or text file named for a day more than a day before today
38
+ (`briefs/daily-brief-2025-06-12.md`, created on 2026-11-01) is refused once
39
+ with the real date. If the date is meant, Claude writes the same file again
40
+ and it goes through. Today, yesterday and later days (a plan) are not judged,
41
+ nor files that already exist, nor dates in folder names.
30
42
  - What counts as a stamp: a date in a Markdown heading (`## 0.2.0 (2026-11-03)`),
31
43
  a labelled field that starts its line (`Date:`, `Updated:`, `Released:`,
32
44
  `date:` in frontmatter), or a date or date and time at the start of a line. A
@@ -41,8 +53,14 @@ screen.
41
53
  `.text`, `.rst`, `.adoc`, `.asciidoc`, `.org`, `.log`, and `CHANGELOG`, `HISTORY`, `NEWS`, `RELEASES`,
42
54
  `NOTES` with no extension). A date in code is data, not a stamp.
43
55
  - 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.
56
+ (`2026-10-31 16:05`, `2026-10-31T20:05Z`, `2026-10-31 16:05 EST`); a bare `16:05`
57
+ or `16:05 EST` counts at the start of a line in a file named for the day
58
+ (`log/2026-10-31.md`). Other formats (`Oct 31, 2026`) are not checked yet.
59
+ - Zone names read after a time: UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT,
60
+ CET, CEST, BST, IST. A time with any other name, or none, is your local time.
61
+ A name used in more than one place (`CST`, `IST`, `BST`) is only called wrong if
62
+ it is wrong for every place it can mean. In the hour the clocks go back over,
63
+ when 01:30 happens twice, a local time is only called wrong if it is wrong both times.
46
64
  - It checks Write, Edit, and shell commands where `echo` or `printf` writes to
47
65
  a file with `>>`, `>` or `| tee`, or `cat` or `tee` writes a heredoc to one.
48
66
 
@@ -50,6 +68,22 @@ Settings (asked when you enable the plugin): minutes ahead allowed (2), minutes
50
68
  behind before a note (30), days behind before a note (1), notes on old stamps
51
69
  (on).
52
70
 
71
+ ![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)
72
+
73
+ Seen in a Claude Code 2.1.312 terminal (120 columns) on 2026-11-01 at 01:39 EST,
74
+ the night New York's clocks went back. Asked to add the line
75
+ `- 01:58 EST deploy finished`, Claude's edit was refused with the real time and
76
+ its offset, and Claude asked whether the deploy had finished at 01:58 EDT, an
77
+ hour earlier.
78
+
79
+ ![Claude Code's write of briefs/daily-brief-2025-06-12.md refused by date-check with the real date; Claude wrote it again because the name was given, then said the date was likely wrong and offered today's name](https://raw.githubusercontent.com/nightlab-agent/date-check/e008e44b11e50b3115766b19c489e199b7f94ed1/docs/preview-name.png)
80
+
81
+ Seen in the same terminal on 2026-11-01 at 02:23 EST. Asked to create today's
82
+ brief as `briefs/daily-brief-2025-06-12.md`, Claude's first write was refused
83
+ with the real date. Because the request had named the file, Claude wrote it
84
+ again, it went through, and Claude told the person the date was more than a
85
+ year old and offered to rename it to `daily-brief-2026-11-01.md`.
86
+
53
87
  ## What it writes
54
88
 
55
89
  Nothing. It reads the file a Write or an Edit is about to change, to judge only the new
@@ -1,5 +1,5 @@
1
1
  import type { EngineInterface, Register } from 'claude-code'
2
- import { dateOfFile, dayOf, judge, LIMITS, newStamps } from './stamps'
2
+ import { dateOfFile, dayOf, judge, judgeName, LIMITS, newStamps } from './stamps'
3
3
  import type { Limits, Verdict } from './stamps'
4
4
 
5
5
  // date-check, the hooks module. Before a Write, an Edit, or a shell command that appends to
@@ -9,6 +9,8 @@ import type { Limits, Verdict } from './stamps'
9
9
  // the same line written to the same file a second time goes through (a deadline or a plan
10
10
  // can be in the future on purpose), with a note;
11
11
  // - a stamp older than the limits goes through with a note the agent reads after the result.
12
+ // - a new file whose name carries a day more than staleDays past (logs/2025-06-12.md written
13
+ // on 2026-11-01) is refused once with the real date, the same way; a later day is a plan.
12
14
  // Lines the file already had are never judged: only what this call adds. Only Markdown and
13
15
  // text files (isText): a date in code is data, not a stamp.
14
16
 
@@ -52,11 +54,12 @@ export const shellWrite = (command: string): { file: string; text: string } | un
52
54
  }
53
55
 
54
56
  /** What a call changes in which file: the file's text before and after it. */
55
- async function change($: EngineInterface, x: Call): Promise<{ file: string; before: string; after: string }> {
57
+ async function change($: EngineInterface, x: Call): Promise<{ file: string; before: string; after: string; created?: boolean }> {
56
58
  const none = { file: '', before: '', after: '' }
57
59
  if (x.tool === 'Write' && typeof x.content === 'string' && x.file_path) {
58
- const before = (await $.fs.exists(x.file_path)) ? await $.fs.read(x.file_path) : ''
59
- return { file: x.file_path, before, after: x.content }
60
+ const exists = await $.fs.exists(x.file_path)
61
+ const before = exists ? await $.fs.read(x.file_path) : ''
62
+ return { file: x.file_path, before, after: x.content, created: !exists }
60
63
  }
61
64
  if (x.tool === 'Edit' && typeof x.new_string === 'string' && typeof x.old_string === 'string' && x.file_path) {
62
65
  const before = (await $.fs.exists(x.file_path)) ? await $.fs.read(x.file_path) : ''
@@ -69,7 +72,11 @@ async function change($: EngineInterface, x: Call): Promise<{ file: string; befo
69
72
  }
70
73
  if (x.tool === 'Bash' && typeof x.command === 'string') {
71
74
  const w = shellWrite(x.command)
72
- return w ? { file: w.file, before: '', after: w.text } : none
75
+ if (!w) return none
76
+ // Whether the shell's file is new is only known when its path means one place: not under
77
+ // `~` (the engine does not expand it) and not after a `cd` in the same command.
78
+ const sure = !w.file.startsWith('~') && !/(?:^|[;&|\n]\s*)(?:cd|pushd)\s/.test(x.command)
79
+ return { file: w.file, before: '', after: w.text, created: sure && !(await $.fs.exists(w.file)) }
73
80
  }
74
81
  return none
75
82
  }
@@ -78,11 +85,16 @@ const quote = (v: Verdict) => `"${v.stamp.text}" (in: ${v.stamp.line})`
78
85
 
79
86
  export const refusal = (vs: Verdict[]) =>
80
87
  `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. ` +
88
+ `It is ${vs[0]!.real} now. Write the real date and time, or leave the stamp out. ` +
82
89
  `If a future date is meant (a deadline, a planned release), write the same text again and it goes through.`
83
90
 
91
+ export const nameRefusal = (file: string, n: { date: string; real: string }) =>
92
+ `date-check: the new file's name says ${n.date} (${file.split(/[/\\]/).pop()}), a day that has passed. ` +
93
+ `It is ${n.real} now. If the file is for today, name it for today. ` +
94
+ `If that date is meant (a record of that day), write the same file again and it goes through.`
95
+
84
96
  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). ` +
97
+ `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} older than now (${vs[0]!.real}). ` +
86
98
  `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
99
 
88
100
  export const register: Register = (on, options: Options = {}) => {
@@ -103,11 +115,13 @@ export const register: Register = (on, options: Options = {}) => {
103
115
  if (x.tool !== 'Write' && x.tool !== 'Edit' && x.tool !== 'Bash') return next(e)
104
116
  const began = ++order
105
117
  let verdicts: Verdict[] = []
118
+ let name: ReturnType<typeof judgeName>
106
119
  let file = ''
107
120
  try {
108
121
  const now = await $.clock.now()
109
122
  const w = await change($, x)
110
123
  file = w.file
124
+ if (file && isText(file) && w.created) name = judgeName(file, now, limits)
111
125
  if (file && isText(file) && w.after) {
112
126
  // Bare times in a file named for a later day are a plan for that day, not stamps.
113
127
  const day = dateOfFile(file)
@@ -116,17 +130,25 @@ export const register: Register = (on, options: Options = {}) => {
116
130
  } catch {
117
131
  // A failure of this mod never stops the call.
118
132
  verdicts = []
133
+ name = undefined
119
134
  }
120
135
  const key = (v: Verdict) => `${file}\n${v.stamp.line}`
121
136
  const future = verdicts.filter(v => v.kind === 'future')
122
137
  const fresh = future.filter(v => !((meant.get(key(v)) ?? Infinity) < began))
123
- if (fresh.length) {
124
- for (const v of fresh) meant.set(key(v), ++order)
125
- return { deny: refusal(fresh) }
138
+ // By the file's name alone, so a retry spelled another way (relative, absolute) is the same file.
139
+ const nameKey = `(name)\n${file.split(/[/\\]/).pop()}`
140
+ const freshName = name && !((meant.get(nameKey) ?? Infinity) < began) ? name : undefined
141
+ if (fresh.length || freshName) {
142
+ // The first refusal's number is kept: an older call refused late must not move it past a retry.
143
+ const mark = (k: string) => { const n = ++order; meant.set(k, Math.min(meant.get(k) ?? Infinity, n)) }
144
+ for (const v of fresh) mark(key(v))
145
+ if (freshName) mark(nameKey)
146
+ return { deny: [...(freshName ? [nameRefusal(file, freshName)] : []), ...(fresh.length ? [refusal(fresh)] : [])].join('\n') }
126
147
  }
127
148
  const done = await next(e)
128
149
  if (done.deny !== undefined || done.isError) return done
129
150
  const said = [
151
+ ...(name ? [`date-check: kept the date ${name.date} in the new file's name as written the second time.`] : []),
130
152
  ...(future.length ? [`date-check: kept ${future.map(v => `"${v.stamp.text}"`).join(', ')} as written the second time (a date in the future on purpose).`] : []),
131
153
  ...(notes && verdicts.some(v => v.kind === 'stale') ? [staleNote(verdicts.filter(v => v.kind === 'stale'))] : []),
132
154
  ]
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,12 +214,26 @@ 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
  }
172
225
  return out
173
226
  }
227
+
228
+ /**
229
+ * The date in a new file's name, when it is more than `staleDays` before today: the agent
230
+ * named the file for the day it guessed, often one near its training. A later day is a plan
231
+ * (an agenda for 2026-11-05), not judged. `undefined` when the name carries no date or the
232
+ * date can be today.
233
+ */
234
+ export const judgeName = (path: string, now: number, limits: Limits = LIMITS): { date: string; real: string } | undefined => {
235
+ const date = dateOfFile(path)
236
+ if (!date) return undefined
237
+ const v = judge([{ text: date, line: path, where: 'field', date }], now, limits)[0]
238
+ return v?.kind === 'stale' ? { date, real: v.real } : undefined
239
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlab-agent/date-check",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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",