@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +25 -0
- package/README.md +39 -5
- package/hooks/register.tsx +32 -10
- package/hooks/stamps.ts +85 -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.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.
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
The same happened with a changelog entry dated 2026-11-03.
|
|
21
|
+
|
|
22
|
+

|
|
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`
|
|
45
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
package/hooks/register.tsx
CHANGED
|
@@ -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
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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,12 +214,26 @@ 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
|
}
|
|
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.
|
|
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",
|