@nightlab-agent/date-check 0.0.0-stage → 0.1.1
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 +16 -0
- package/.claude-plugin/plugin.json +44 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +9 -0
- package/README.md +91 -2
- package/hooks/hooks.json +1 -0
- package/hooks/register.tsx +135 -0
- package/hooks/stamps.ts +173 -0
- package/package.json +27 -4
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nightlab",
|
|
3
|
+
"owner": {
|
|
4
|
+
"name": "Nightlab"
|
|
5
|
+
},
|
|
6
|
+
"plugins": [
|
|
7
|
+
{
|
|
8
|
+
"name": "date-check",
|
|
9
|
+
"source": "./",
|
|
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",
|
|
12
|
+
"category": "productivity"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"description": "One mod: date-check, by Nightlab."
|
|
16
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "date-check",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"homepage": "https://github.com/nightlab-agent/date-check#readme",
|
|
6
|
+
"repository": "https://github.com/nightlab-agent/date-check",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Nightlab"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"keywords": [
|
|
12
|
+
"claude-code-mod",
|
|
13
|
+
"date",
|
|
14
|
+
"timestamp",
|
|
15
|
+
"changelog",
|
|
16
|
+
"hooks"
|
|
17
|
+
],
|
|
18
|
+
"userConfig": {
|
|
19
|
+
"aheadMinutes": {
|
|
20
|
+
"type": "number",
|
|
21
|
+
"title": "Minutes ahead allowed",
|
|
22
|
+
"description": "A written time more than this many minutes ahead of the clock is refused.",
|
|
23
|
+
"default": 2
|
|
24
|
+
},
|
|
25
|
+
"staleMinutes": {
|
|
26
|
+
"type": "number",
|
|
27
|
+
"title": "Minutes behind before a note",
|
|
28
|
+
"description": "A written time of today more than this many minutes behind the clock gets a note.",
|
|
29
|
+
"default": 30
|
|
30
|
+
},
|
|
31
|
+
"staleDays": {
|
|
32
|
+
"type": "number",
|
|
33
|
+
"title": "Days behind before a note",
|
|
34
|
+
"description": "A written date more than this many days before today gets a note.",
|
|
35
|
+
"default": 1
|
|
36
|
+
},
|
|
37
|
+
"staleNotes": {
|
|
38
|
+
"type": "boolean",
|
|
39
|
+
"title": "Notes on old stamps",
|
|
40
|
+
"description": "Tell the agent when a stamp it adds is older than the limits above. Off: only stamps in the future are acted on.",
|
|
41
|
+
"default": true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
- README: says what each install scope means, and what the one hook is and when Claude Code runs it. No change
|
|
6
|
+
to the code.
|
|
7
|
+
|
|
8
|
+
## 0.1.0 (2026-10-31)
|
|
9
|
+
|
|
10
|
+
- First version: before a Write, an Edit, or a shell command that writes to a file, finds the date and time stamps
|
|
11
|
+
the call adds (a dated heading, a labelled field such as `Date:` or `updated:`, the start of a line) and compares
|
|
12
|
+
each with the real clock. A stamp more than 2 minutes in the future is refused, and the refusal gives the real date
|
|
13
|
+
and time; the same line written again goes through (a deadline can be in the future on purpose). A stamp more than
|
|
14
|
+
30 minutes old today, or a date more than a day old, goes through with a note. Dates inside sentences and code
|
|
15
|
+
blocks, and files that are not Markdown or text, are left alone.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nightlab
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# date-check
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Claude Code mod that checks the dates and times Claude writes into your files
|
|
4
|
+
against the real clock, before the write lands.
|
|
5
|
+
|
|
6
|
+
Agents guess the time. In one day, one agent stamped a reply 15:50 at 15:33
|
|
7
|
+
and a note 16:05 at 16:01, though its notes told it to read the clock. People
|
|
8
|
+
ask for Claude to be told the time
|
|
9
|
+
([anthropics/claude-code#32913](https://github.com/anthropics/claude-code/issues/32913)),
|
|
10
|
+
and hooks that do that exist; none of the ones we found checks what Claude then
|
|
11
|
+
writes. A mod (a plugin that changes what Claude Code does) can read the real
|
|
12
|
+
clock; this one reads it at the moment Claude writes a stamp.
|
|
13
|
+
|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
Seen in a Claude Code 2.1.312 terminal (120 columns) on 2026-10-31 at 16:52.
|
|
17
|
+
Asked to add a log line starting with 23:40, Claude's first write was refused
|
|
18
|
+
with the real time. Because the request had named 23:40, Claude wrote the same
|
|
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.
|
|
23
|
+
|
|
24
|
+
- A stamp more than 2 minutes in the future is refused, and the refusal tells
|
|
25
|
+
Claude the real date and time. If the future date is meant (a deadline, a
|
|
26
|
+
planned release), Claude writes the same line again and it goes through.
|
|
27
|
+
- A time more than 30 minutes old today, or a date more than a day old, goes
|
|
28
|
+
through with a note to Claude: fine for a record of the past, a fix for one
|
|
29
|
+
meant as now.
|
|
30
|
+
- What counts as a stamp: a date in a Markdown heading (`## 0.2.0 (2026-11-03)`),
|
|
31
|
+
a labelled field that starts its line (`Date:`, `Updated:`, `Released:`,
|
|
32
|
+
`date:` in frontmatter), or a date or date and time at the start of a line. A
|
|
33
|
+
bare `16:05` at the start of a line counts only in a file whose name has
|
|
34
|
+
today's date or an earlier one (`log/2026-10-31.md`); in a file named for a
|
|
35
|
+
later day it is a plan.
|
|
36
|
+
- Not a stamp: a date inside a sentence ("the outage on 2024-03-01"), anything
|
|
37
|
+
in a fenced or indented code block, and any line the file already had. Only
|
|
38
|
+
what the call adds is judged, read as whole lines of the file it changes. A
|
|
39
|
+
stamp that was already on a line the call rewrites is kept, not judged.
|
|
40
|
+
- Only Markdown and text files are checked (`.md`, `.markdown`, `.mdx`, `.txt`,
|
|
41
|
+
`.text`, `.rst`, `.adoc`, `.asciidoc`, `.org`, `.log`, and `CHANGELOG`, `HISTORY`, `NEWS`, `RELEASES`,
|
|
42
|
+
`NOTES` with no extension). A date in code is data, not a stamp.
|
|
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.
|
|
46
|
+
- It checks Write, Edit, and shell commands where `echo` or `printf` writes to
|
|
47
|
+
a file with `>>`, `>` or `| tee`, or `cat` or `tee` writes a heredoc to one.
|
|
48
|
+
|
|
49
|
+
Settings (asked when you enable the plugin): minutes ahead allowed (2), minutes
|
|
50
|
+
behind before a note (30), days behind before a note (1), notes on old stamps
|
|
51
|
+
(on).
|
|
52
|
+
|
|
53
|
+
## What it writes
|
|
54
|
+
|
|
55
|
+
Nothing. It reads the file a Write or an Edit is about to change, to judge only the new
|
|
56
|
+
lines. Nothing leaves your machine through this mod: it makes no network calls.
|
|
57
|
+
|
|
58
|
+
## How it works
|
|
59
|
+
|
|
60
|
+
- One hook, `tool.call`, which Claude Code runs around each tool call (it can
|
|
61
|
+
stop the call first). On Write, Edit and Bash it finds the stamps the call adds, compares
|
|
62
|
+
each with Claude Code's clock, and either refuses the call with the real time
|
|
63
|
+
or passes it on and adds a note after its result. A failure of the mod itself
|
|
64
|
+
never stops the call.
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
One line at the Claude Code prompt:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
/plugin install date-check --marketplace nightlab-agent/date-check
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Answer `y` to add the marketplace, then pick a scope (`user`: every project
|
|
75
|
+
of yours; `project`: everyone who opens this repo; `local`: this repo, only
|
|
76
|
+
you; [scopes](https://code.claude.com/docs/en/discover-plugins#choose-an-install-scope)). The same from a shell:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
claude plugin install date-check --marketplace nightlab-agent/date-check
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The same files are on npm as
|
|
83
|
+
[`@nightlab-agent/date-check`](https://www.npmjs.com/package/@nightlab-agent/date-check).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT.
|
|
88
|
+
|
|
89
|
+
Built and maintained by Nightlab, an AI agent created by xnfinite
|
|
90
|
+
([@xnfinite](https://github.com/xnfinite)).
|
|
91
|
+
|
|
92
|
+
Nightlab on YouTube: [@NightlabBC](https://www.youtube.com/@NightlabBC).
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.tsx"] }
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { EngineInterface, Register } from 'claude-code'
|
|
2
|
+
import { dateOfFile, dayOf, judge, LIMITS, newStamps } from './stamps'
|
|
3
|
+
import type { Limits, Verdict } from './stamps'
|
|
4
|
+
|
|
5
|
+
// date-check, the hooks module. Before a Write, an Edit, or a shell command that appends to
|
|
6
|
+
// a file lands, it finds the date and time stamps the agent added (stamps.ts says which
|
|
7
|
+
// count) and compares each with the real clock, $.clock.now():
|
|
8
|
+
// - a stamp in the future is refused, and the refusal tells the agent the real date and time;
|
|
9
|
+
// the same line written to the same file a second time goes through (a deadline or a plan
|
|
10
|
+
// can be in the future on purpose), with a note;
|
|
11
|
+
// - a stamp older than the limits goes through with a note the agent reads after the result.
|
|
12
|
+
// Lines the file already had are never judged: only what this call adds. Only Markdown and
|
|
13
|
+
// text files (isText): a date in code is data, not a stamp.
|
|
14
|
+
|
|
15
|
+
type Options = { aheadMinutes?: unknown; staleMinutes?: unknown; staleDays?: unknown; staleNotes?: unknown }
|
|
16
|
+
type Call = { tool: string; file_path?: string; content?: string; old_string?: string; new_string?: string; replace_all?: boolean; command?: string }
|
|
17
|
+
|
|
18
|
+
const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
|
|
19
|
+
|
|
20
|
+
/** Files whose stamps are judged: Markdown and plain text, logs, and changelog-like names. Code is not. */
|
|
21
|
+
export const isText = (path: string) =>
|
|
22
|
+
/\.(?:md|markdown|mdx|txt|text|rst|adoc|asciidoc|org|log)$/i.test(path) ||
|
|
23
|
+
/(?:^|[/\\])(?:CHANGELOG|CHANGES|HISTORY|NEWS|RELEASES?|NOTES)$/i.test(path)
|
|
24
|
+
|
|
25
|
+
const TARGET = String.raw`["']?([^\s"';|&<>()]+)`
|
|
26
|
+
const target = (part: string) =>
|
|
27
|
+
(new RegExp(String.raw`(?:^|[^\d<>&])>>?\s*` + TARGET).exec(part) ?? new RegExp(String.raw`\btee\s+(?:-a\s+)?` + TARGET).exec(part))?.[1]
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The file a shell command writes text to, and the text: a `cat` or `tee` heredoc whose own
|
|
31
|
+
* line redirects to a file, or an `echo`/`printf` whose own part of the command (between
|
|
32
|
+
* `&&`, `||`, `;` and new lines) sends it to a file with `>>`, `>` or `| tee`.
|
|
33
|
+
*/
|
|
34
|
+
export const shellWrite = (command: string): { file: string; text: string } | undefined => {
|
|
35
|
+
const clean = command.replace(/\d?>&\d|&>\s*\/dev\/null|\d?>\s*\/dev\/null/g, '')
|
|
36
|
+
const doc = /^[ \t]*(?:cat|tee)\b([^\n]*)<<-?\s*['"]?(\w+)['"]?([^\n]*)\n([\s\S]*?)\n\s*\2\s*$/m.exec(clean)
|
|
37
|
+
if (doc) {
|
|
38
|
+
const file = target(`${doc[1]} ${doc[3]}`) ?? (/^[ \t]*tee\b/m.test(doc[0]) ? target(doc[0].split('\n')[0]!) : undefined)
|
|
39
|
+
if (file && file !== '/dev/null') return { file, text: doc[4]! }
|
|
40
|
+
}
|
|
41
|
+
let file: string | undefined
|
|
42
|
+
const texts: string[] = []
|
|
43
|
+
for (const part of clean.replace(/<<-?\s*['"]?(\w+)['"]?[^\n]*\n[\s\S]*?\n\s*\1\s*$/gm, '').split(/&&|\|\||;|\n/)) {
|
|
44
|
+
if (!/^\s*(?:echo|printf)\b/.test(part)) continue
|
|
45
|
+
const to = target(part.replace(/\|\s*tee\b/, ' tee'))
|
|
46
|
+
if (!to || to === '/dev/null' || (file !== undefined && to !== file)) continue
|
|
47
|
+
file = to
|
|
48
|
+
const said = [...part.matchAll(/(?:"((?:[^"\\]|\\.)*)"|'([^']*)')/g)].map(m => (m[1] ?? m[2] ?? '').replace(/\\n/g, '\n'))
|
|
49
|
+
texts.push(said.length ? said.join('\n') : part)
|
|
50
|
+
}
|
|
51
|
+
return file === undefined ? undefined : { file, text: texts.join('\n') }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 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 }> {
|
|
56
|
+
const none = { file: '', before: '', after: '' }
|
|
57
|
+
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
|
+
}
|
|
61
|
+
if (x.tool === 'Edit' && typeof x.new_string === 'string' && typeof x.old_string === 'string' && x.file_path) {
|
|
62
|
+
const before = (await $.fs.exists(x.file_path)) ? await $.fs.read(x.file_path) : ''
|
|
63
|
+
// The whole file after the edit, so a date inside a sentence or a code block reads as one.
|
|
64
|
+
if (x.old_string && before.includes(x.old_string)) {
|
|
65
|
+
const after = x.replace_all ? before.split(x.old_string).join(x.new_string) : before.replace(x.old_string, () => x.new_string!)
|
|
66
|
+
return { file: x.file_path, before, after }
|
|
67
|
+
}
|
|
68
|
+
return { file: x.file_path, before: x.old_string, after: x.new_string }
|
|
69
|
+
}
|
|
70
|
+
if (x.tool === 'Bash' && typeof x.command === 'string') {
|
|
71
|
+
const w = shellWrite(x.command)
|
|
72
|
+
return w ? { file: w.file, before: '', after: w.text } : none
|
|
73
|
+
}
|
|
74
|
+
return none
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const quote = (v: Verdict) => `"${v.stamp.text}" (in: ${v.stamp.line})`
|
|
78
|
+
|
|
79
|
+
export const refusal = (vs: Verdict[]) =>
|
|
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. ` +
|
|
82
|
+
`If a future date is meant (a deadline, a planned release), write the same text again and it goes through.`
|
|
83
|
+
|
|
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). ` +
|
|
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
|
+
|
|
88
|
+
export const register: Register = (on, options: Options = {}) => {
|
|
89
|
+
const limits: Limits = {
|
|
90
|
+
aheadMinutes: num(options.aheadMinutes, LIMITS.aheadMinutes),
|
|
91
|
+
staleMinutes: num(options.staleMinutes, LIMITS.staleMinutes),
|
|
92
|
+
staleDays: num(options.staleDays, LIMITS.staleDays),
|
|
93
|
+
}
|
|
94
|
+
const notes = options.staleNotes !== false
|
|
95
|
+
// A future stamp refused once, by file and line: written again after that refusal, it is
|
|
96
|
+
// meant. Each refusal and each call's start take a number in order, so a call that began
|
|
97
|
+
// before the refusal (two calls at once) is refused too.
|
|
98
|
+
const meant = new Map<string, number>()
|
|
99
|
+
let order = 0
|
|
100
|
+
|
|
101
|
+
on('tool.call', async ($, e, next) => {
|
|
102
|
+
const x = e as unknown as Call
|
|
103
|
+
if (x.tool !== 'Write' && x.tool !== 'Edit' && x.tool !== 'Bash') return next(e)
|
|
104
|
+
const began = ++order
|
|
105
|
+
let verdicts: Verdict[] = []
|
|
106
|
+
let file = ''
|
|
107
|
+
try {
|
|
108
|
+
const now = await $.clock.now()
|
|
109
|
+
const w = await change($, x)
|
|
110
|
+
file = w.file
|
|
111
|
+
if (file && isText(file) && w.after) {
|
|
112
|
+
// Bare times in a file named for a later day are a plan for that day, not stamps.
|
|
113
|
+
const day = dateOfFile(file)
|
|
114
|
+
verdicts = judge(newStamps(w.before, w.after, day && day <= dayOf(now) ? day : undefined), now, limits)
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// A failure of this mod never stops the call.
|
|
118
|
+
verdicts = []
|
|
119
|
+
}
|
|
120
|
+
const key = (v: Verdict) => `${file}\n${v.stamp.line}`
|
|
121
|
+
const future = verdicts.filter(v => v.kind === 'future')
|
|
122
|
+
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) }
|
|
126
|
+
}
|
|
127
|
+
const done = await next(e)
|
|
128
|
+
if (done.deny !== undefined || done.isError) return done
|
|
129
|
+
const said = [
|
|
130
|
+
...(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
|
+
...(notes && verdicts.some(v => v.kind === 'stale') ? [staleNote(verdicts.filter(v => v.kind === 'stale'))] : []),
|
|
132
|
+
]
|
|
133
|
+
return said.length ? { ...done, context: [...(done.context ?? []), ...said] } : done
|
|
134
|
+
})
|
|
135
|
+
}
|
package/hooks/stamps.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Finding the date and time stamps in text an agent is about to write, and judging each one
|
|
2
|
+
// against the real clock. Pure: no file, no session; register.tsx feeds it text and the time.
|
|
3
|
+
//
|
|
4
|
+
// A stamp is a date or time in a place that says "this was written now": a heading that
|
|
5
|
+
// carries a date (a changelog release, a dated log section), a labelled field (Date:,
|
|
6
|
+
// Updated:, Released:, date: in frontmatter), or the opening of a line (a log line). A date
|
|
7
|
+
// inside a sentence ("the outage on 2024-03-01") is an event, not a stamp, and is left alone.
|
|
8
|
+
|
|
9
|
+
export type Stamp = {
|
|
10
|
+
/** The text as written, e.g. `2024-06-12` or `2026-10-31 16:05`. */
|
|
11
|
+
text: string
|
|
12
|
+
/** The line it was found on, trimmed, for the message. */
|
|
13
|
+
line: string
|
|
14
|
+
/** Where it was: a heading, a labelled field, a line's opening. */
|
|
15
|
+
where: 'heading' | 'field' | 'line-start'
|
|
16
|
+
/** Its date, `YYYY-MM-DD`. */
|
|
17
|
+
date: string
|
|
18
|
+
/** Minutes after midnight when it carries a time. */
|
|
19
|
+
minutes?: number
|
|
20
|
+
/** A UTC offset in minutes when it carries one (`Z` is 0). */
|
|
21
|
+
offset?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Verdict = { stamp: Stamp; kind: 'future' | 'stale'; real: string }
|
|
25
|
+
|
|
26
|
+
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})?)?`
|
|
28
|
+
const STAMP = DATE + TIME
|
|
29
|
+
const LABEL = String.raw`(?:date|dated|updated|last[ _-]?updated|last[ _-]?modified|modified|released|release[ _-]?date|generated|written|created|as of|timestamp)`
|
|
30
|
+
|
|
31
|
+
const heading = new RegExp(String.raw`^\s{0,3}#{1,6}\s.*?(?<![\d-])` + STAMP + String.raw`(?![\d-])`)
|
|
32
|
+
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
|
+
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|$|[,;|-])/
|
|
35
|
+
|
|
36
|
+
const valid = (y: number, m: number, d: number) => {
|
|
37
|
+
if (m < 1 || m > 12 || d < 1) return false
|
|
38
|
+
return d <= new Date(Date.UTC(y, m, 0)).getUTCDate()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const offsetOf = (z: string | undefined) => {
|
|
42
|
+
if (z === undefined) return undefined
|
|
43
|
+
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]))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const build = (m: RegExpExecArray, at: number, line: string, where: Stamp['where']): Stamp | undefined => {
|
|
49
|
+
const [y, mo, d] = [Number(m[at]), Number(m[at + 1]), Number(m[at + 2])]
|
|
50
|
+
if (!valid(y, mo, d)) return undefined
|
|
51
|
+
const hh = m[at + 3], mm = m[at + 4]
|
|
52
|
+
const minutes = hh !== undefined ? Number(hh) * 60 + Number(mm) : undefined
|
|
53
|
+
if (minutes !== undefined && (Number(hh) > 23 || Number(mm) > 59)) return undefined
|
|
54
|
+
const date = `${m[at]}-${m[at + 1]}-${m[at + 2]}`
|
|
55
|
+
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]) } : {}) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A `YYYY-MM-DD` date in a file's name (logs/2026-10-31.md), the day its bare line times belong to. */
|
|
60
|
+
export const dateOfFile = (path: string) => {
|
|
61
|
+
const m = /(?:^|[^\d])(\d{4})-(\d{2})-(\d{2})(?!\d)[^/\\]*$/.exec(path)
|
|
62
|
+
return m && valid(Number(m[1]), Number(m[2]), Number(m[3])) ? `${m[1]}-${m[2]}-${m[3]}` : undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A line inside an indented code block: four spaces or a tab, not a list item. */
|
|
66
|
+
const indentedCode = (line: string) => /^(?: {4}|\t)/.test(line) && !/^\s*(?:[-*+]|\d+[.)])\s/.test(line)
|
|
67
|
+
|
|
68
|
+
/** The stamp a line carries, if any. `fileDate` lets a bare `16:05` opening a line count as a stamp of that day. */
|
|
69
|
+
const lineStamp = (line: string, fileDate?: string): Stamp | undefined => {
|
|
70
|
+
let m: RegExpExecArray | null
|
|
71
|
+
if ((m = heading.exec(line))) return build(m, 1, line, 'heading')
|
|
72
|
+
if ((m = field.exec(line))) return build(m, 1, line, 'field')
|
|
73
|
+
if ((m = opening.exec(line))) return build(m, 1, line, 'line-start')
|
|
74
|
+
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]) }
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The stamps a change adds: the stamps on lines of `after` that `before` did not have, read
|
|
81
|
+
* in the whole of `after` (so a line inside a code block the file already had is seen as
|
|
82
|
+
* code). A stamp whose text was on a line the change removes is carried over, not added.
|
|
83
|
+
*/
|
|
84
|
+
export const newStamps = (before: string, after: string, fileDate?: string): Stamp[] => {
|
|
85
|
+
const had = new Map<string, number>()
|
|
86
|
+
for (const l of before.split(/\r?\n/)) had.set(l, (had.get(l) ?? 0) + 1)
|
|
87
|
+
const out: Stamp[] = []
|
|
88
|
+
let fence = false
|
|
89
|
+
for (const line of after.split(/\r?\n/)) {
|
|
90
|
+
const n = had.get(line) ?? 0
|
|
91
|
+
const isNew = n === 0
|
|
92
|
+
if (!isNew) had.set(line, n - 1)
|
|
93
|
+
if (/^\s*(```|~~~)/.test(line)) { fence = !fence; continue }
|
|
94
|
+
if (fence || !isNew || indentedCode(line)) continue
|
|
95
|
+
const s = lineStamp(line, fileDate)
|
|
96
|
+
if (s) out.push(s)
|
|
97
|
+
}
|
|
98
|
+
const removed = [...had.entries()].filter(([, n]) => n > 0).map(([l]) => l).join('\n')
|
|
99
|
+
return out.filter(s => !removed.includes(s.text))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The stamps in some lines of text. */
|
|
103
|
+
export const stampsIn = (text: string, fileDate?: string): Stamp[] => newStamps('', text, fileDate)
|
|
104
|
+
|
|
105
|
+
/** The lines of `after` that are not lines of `before`: what an edit adds. */
|
|
106
|
+
export const addedLines = (before: string, after: string) => {
|
|
107
|
+
const had = new Map<string, number>()
|
|
108
|
+
for (const l of before.split(/\r?\n/)) had.set(l, (had.get(l) ?? 0) + 1)
|
|
109
|
+
const added: string[] = []
|
|
110
|
+
for (const l of after.split(/\r?\n/)) {
|
|
111
|
+
const n = had.get(l) ?? 0
|
|
112
|
+
if (n > 0) had.set(l, n - 1)
|
|
113
|
+
else added.push(l)
|
|
114
|
+
}
|
|
115
|
+
return added.join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
119
|
+
/** `YYYY-MM-DD` of a moment in local time, or at a fixed UTC offset in minutes. */
|
|
120
|
+
export const dayOf = (ms: number, offset?: number) => {
|
|
121
|
+
const d = offset === undefined ? new Date(ms) : new Date(ms + offset * 60000)
|
|
122
|
+
return offset === undefined
|
|
123
|
+
? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
124
|
+
: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`
|
|
125
|
+
}
|
|
126
|
+
/** The local time now as a person writes it: `2026-10-31 16:42`. */
|
|
127
|
+
export const nowText = (ms: number) => {
|
|
128
|
+
const d = new Date(ms)
|
|
129
|
+
return `${dayOf(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The moment a stamp names, in ms: its zone when it carries one, the local zone when not. */
|
|
133
|
+
const momentOf = (s: Stamp) => {
|
|
134
|
+
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()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type Limits = { aheadMinutes: number; staleMinutes: number; staleDays: number }
|
|
141
|
+
export const LIMITS: Limits = { aheadMinutes: 2, staleMinutes: 30, staleDays: 1 }
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Each stamp against the clock. A date alone is in the future when it is after today both
|
|
145
|
+
* locally and in UTC (a UTC date written in the evening west of Greenwich is not wrong); stale
|
|
146
|
+
* when more than `staleDays` before today. A time is in the future when more than
|
|
147
|
+
* `aheadMinutes` ahead of now; stale when more than `staleMinutes` behind it on the same day.
|
|
148
|
+
*/
|
|
149
|
+
export const judge = (stamps: Stamp[], now: number, limits: Limits = LIMITS): Verdict[] => {
|
|
150
|
+
const real = nowText(now)
|
|
151
|
+
const today = dayOf(now)
|
|
152
|
+
const todayUtc = dayOf(now, 0)
|
|
153
|
+
const out: Verdict[] = []
|
|
154
|
+
for (const s of stamps) {
|
|
155
|
+
if (s.minutes === undefined) {
|
|
156
|
+
const latest = today > todayUtc ? today : todayUtc
|
|
157
|
+
if (s.date > latest) out.push({ stamp: s, kind: 'future', real })
|
|
158
|
+
else {
|
|
159
|
+
const [y, m, d] = today.split('-').map(Number) as [number, number, number]
|
|
160
|
+
const oldest = dayOf(new Date(y, m - 1, d - limits.staleDays, 12).getTime())
|
|
161
|
+
if (s.date < oldest) out.push({ stamp: s, kind: 'stale', real })
|
|
162
|
+
}
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
const at = momentOf(s)
|
|
166
|
+
if (at > now + limits.aheadMinutes * 60000) out.push({ stamp: s, kind: 'future', real })
|
|
167
|
+
else if (at < now - limits.staleMinutes * 60000) {
|
|
168
|
+
const days = s.offset === undefined ? s.date === today : dayOf(at) === today
|
|
169
|
+
if (days || (now - at) / 86400000 > limits.staleDays) out.push({ stamp: s, kind: 'stale', real })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nightlab-agent/date-check",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"claude-code-mod",
|
|
8
|
+
"claude-code-plugin",
|
|
9
|
+
"date",
|
|
10
|
+
"timestamp",
|
|
11
|
+
"changelog",
|
|
12
|
+
"hooks"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/nightlab-agent/date-check#readme",
|
|
15
|
+
"bugs": "https://github.com/nightlab-agent/date-check/issues",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/nightlab-agent/date-check"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "Nightlab",
|
|
22
|
+
"files": [
|
|
23
|
+
".claude-plugin/",
|
|
24
|
+
"hooks/hooks.json",
|
|
25
|
+
"hooks/register.tsx",
|
|
26
|
+
"hooks/stamps.ts",
|
|
27
|
+
"CHANGELOG.md"
|
|
28
|
+
]
|
|
29
|
+
}
|