@nightlab-agent/date-check 0.1.2 → 0.1.4

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.2",
11
+ "version": "0.1.4",
12
12
  "category": "productivity"
13
13
  }
14
14
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "date-check",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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": {
@@ -39,6 +39,18 @@
39
39
  "title": "Notes on old stamps",
40
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
41
  "default": true
42
+ },
43
+ "promptClock": {
44
+ "type": "boolean",
45
+ "title": "The clock on every prompt",
46
+ "description": "Each prompt carries the local weekday, date, time and zone, and how long ago your previous message came, so Claude has the real time instead of a guess. Off: nothing is added to prompts.",
47
+ "default": true
48
+ },
49
+ "fill": {
50
+ "type": "boolean",
51
+ "title": "Fill placeholders",
52
+ "description": "Replace ⟦NOW⟧, ⟦DATE⟧, ⟦TIME⟧ and ⟦NOW-ISO⟧ in what Claude writes with the local time as the write lands, so Claude need not type the time. Off: placeholders stay as written.",
53
+ "default": true
42
54
  }
43
55
  }
44
56
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 (2026-11-02)
4
+
5
+ - Every prompt now carries the clock, so Claude knows the time instead of guessing it: the
6
+ local weekday, date, time, UTC offset and zone name, and how long ago your previous message
7
+ came (`it is Sunday 2026-11-01 05:58 local time, UTC-05:00 (America/New_York, EST). The
8
+ person's previous message came 3 h 12 min ago.`). A background task's notification or
9
+ another plugin's prompt gets the time but does not count as your previous message. The
10
+ "clock on every prompt" setting turns it off. Before, Claude Code gave Claude the date only,
11
+ with no time and no zone.
12
+ - Claude can leave the time to the clock: `⟦NOW⟧` (date and time), `⟦DATE⟧`, `⟦TIME⟧` or
13
+ `⟦NOW-ISO⟧` in what it writes (a Write or an Edit and its file name, a shell command) is replaced with the
14
+ machine's local time as the call lands, and Claude is told what went in. One inside
15
+ backticks or a code block is kept as written. The prompt clock tells Claude about `⟦NOW⟧`, `⟦DATE⟧`
16
+ and `⟦TIME⟧`; the "fill placeholders" setting turns them off. The names follow a request in
17
+ MikalaiKryvusha/KAIF#170, which found that a clock in the prompt still left an agent writing
18
+ the minute after.
19
+
20
+ ## 0.1.3 (2026-11-01)
21
+
22
+ - A new Markdown or text file whose name carries a date more than a day before today
23
+ (`briefs/daily-brief-2025-06-12.md` created on 2026-11-01) is refused once, with the real
24
+ date. If that date is meant (a record of that day), Claude writes the same file again and it
25
+ goes through. A name with today's date, yesterday's or a later day's (a plan) is not judged,
26
+ nor a file that already exists, nor a date in a folder's name. The days allowed follow the
27
+ "days behind" setting. Before, only the stamps inside a file were checked, never its name.
28
+
3
29
  ## 0.1.2 (2026-11-01)
4
30
 
5
31
  - These zone names after a time are now read: UTC, GMT, EST, EDT, CST, CDT, MST, MDT, PST, PDT,
package/README.md CHANGED
@@ -3,13 +3,21 @@
3
3
  A Claude Code mod that checks the dates and times Claude writes into your files
4
4
  against the real clock, before the write lands.
5
5
 
6
+ Install with one line at the Claude Code prompt (scopes, the shell form and npm
7
+ are under [Install](#install)):
8
+
9
+ ```
10
+ /plugin install date-check --marketplace nightlab-agent/date-check
11
+ ```
12
+
6
13
  Agents guess the time. In one day, one agent stamped a reply 15:50 at 15:33
7
14
  and a note 16:05 at 16:01, though its notes told it to read the clock. People
8
15
  ask for Claude to be told the time
9
16
  ([anthropics/claude-code#32913](https://github.com/anthropics/claude-code/issues/32913)),
10
17
  and hooks that do that exist; none of the ones we found checks what Claude then
11
18
  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.
19
+ clock; this one does both: it tells Claude the time with every prompt, and it
20
+ reads the clock again at the moment Claude writes a stamp.
13
21
 
14
22
  ![Claude Code refused a log line stamped 23:40 at 16:52 by date-check, with the real time, then writing it on purpose the second time](https://raw.githubusercontent.com/nightlab-agent/date-check/05207bd26080d84aee98e450e2ca5f46a711da66/docs/preview.png)
15
23
 
@@ -17,9 +25,43 @@ Seen in a Claude Code 2.1.312 terminal (120 columns) on 2026-10-31 at 16:52.
17
25
  Asked to add a log line starting with 23:40, Claude's first write was refused
18
26
  with the real time. Because the request had named 23:40, Claude wrote the same
19
27
  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.
28
+ The same happened with a changelog entry dated 2026-11-03.
29
+
30
+ ![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)
31
+
32
+ Seen on 2026-11-01 at 02:11 EST, same terminal. Asked to start a log line with
33
+ the current time and not to run commands, Claude did not guess: it asked for
34
+ the time. Then asked to add `- 00:40 backup started`, it wrote that line; the
35
+ note on the old stamp gave it the real time, and it wrote the line it had held
36
+ back as `- 02:11 release went out`. A stamp Claude guesses on its own has not
37
+ yet been seen on a screen.
38
+
39
+ ![With date-check telling Claude the time on each prompt, Claude wrote a log line starting 05:54 at 05:54 without running a command, then named the day, the time zone and how long ago the previous message came](https://raw.githubusercontent.com/nightlab-agent/date-check/1f80c3f7744ebb8d6edab7dfb767d82b42cb2e5b/docs/preview-clock.png)
40
+
41
+ Seen on 2026-11-01 at 05:54 EST, same terminal, with the clock on every
42
+ prompt (new in 0.1.4). The same request as above, to
43
+ start a log line with the current time and not run commands: this time Claude
44
+ wrote `05:54 Release went out.` and said the time came from the session
45
+ context. Asked what day and time it was, which zone, and how long ago the
46
+ previous message came, it answered Sunday 1 November, 05:54, America/New_York
47
+ on Eastern Standard Time, under a minute ago.
48
+
49
+ - Each prompt carries one line Claude reads and you do not see: the local
50
+ weekday, date, time, UTC offset and zone name, and how long ago your
51
+ previous message came. A background task's notification gets the time but
52
+ does not count as your message. The "clock on every prompt" setting turns it off.
53
+ - Claude can leave the time to the clock: `⟦NOW⟧` (date and time), `⟦DATE⟧`,
54
+ `⟦TIME⟧` or `⟦NOW-ISO⟧` in what it writes (a file and its name, an edit, a shell command)
55
+ is replaced with the machine's local time as the write lands, and Claude is
56
+ told what went in. One inside backticks or a code block is kept as written.
57
+ `⟦NOW⟧` has a space in it, so in a file name use `⟦DATE⟧`. The prompt clock
58
+ tells Claude about `⟦NOW⟧`, `⟦DATE⟧` and `⟦TIME⟧`; the "fill placeholders" setting turns them off.
59
+
60
+ ![Claude Code writing a log line, "06:14 The release went out.", and saying the time came from the clock](https://raw.githubusercontent.com/nightlab-agent/date-check/f2fc92e73b769c88db998d6d7078a25bbc5c5b70/docs/preview-fill.png)
61
+
62
+ Seen in a preview on 2026-11-01: asked to start a log line with the exact time
63
+ it is written, Claude wrote `06:14 The release went out.` and said the time
64
+ was filled in from the clock as the file was written.
23
65
 
24
66
  - A stamp more than 2 minutes in the future is refused, and the refusal tells
25
67
  Claude the real date and time. If the future date is meant (a deadline, a
@@ -27,6 +69,11 @@ screen.
27
69
  - A time more than 30 minutes old today, or a date more than a day old, goes
28
70
  through with a note to Claude: fine for a record of the past, a fix for one
29
71
  meant as now.
72
+ - A new Markdown or text file named for a day more than a day before today
73
+ (`briefs/daily-brief-2025-06-12.md`, created on 2026-11-01) is refused once
74
+ with the real date. If the date is meant, Claude writes the same file again
75
+ and it goes through. Today, yesterday and later days (a plan) are not judged,
76
+ nor files that already exist, nor dates in folder names.
30
77
  - What counts as a stamp: a date in a Markdown heading (`## 0.2.0 (2026-11-03)`),
31
78
  a labelled field that starts its line (`Date:`, `Updated:`, `Released:`,
32
79
  `date:` in frontmatter), or a date or date and time at the start of a line. A
@@ -64,13 +111,24 @@ the night New York's clocks went back. Asked to add the line
64
111
  its offset, and Claude asked whether the deploy had finished at 01:58 EDT, an
65
112
  hour earlier.
66
113
 
114
+ ![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)
115
+
116
+ Seen in the same terminal on 2026-11-01 at 02:23 EST. Asked to create today's
117
+ brief as `briefs/daily-brief-2025-06-12.md`, Claude's first write was refused
118
+ with the real date. Because the request had named the file, Claude wrote it
119
+ again, it went through, and Claude told the person the date was more than a
120
+ year old and offered to rename it to `daily-brief-2026-11-01.md`.
121
+
67
122
  ## What it writes
68
123
 
69
- Nothing. It reads the file a Write or an Edit is about to change, to judge only the new
70
- lines. Nothing leaves your machine through this mod: it makes no network calls.
124
+ No files. It adds one line beside each prompt for Claude to read (the clock, above), and it
125
+ reads the file a Write or an Edit is about to change, to judge only the new lines. Nothing leaves your machine through this mod: it makes no network calls.
71
126
 
72
127
  ## How it works
73
128
 
129
+ - One hook, `prompt.submit`, which Claude Code runs as each prompt enters: it reads Claude
130
+ Code's clock and attaches the clock line to the prompt. A failure of the mod lets the
131
+ prompt through unchanged.
74
132
  - One hook, `tool.call`, which Claude Code runs around each tool call (it can
75
133
  stop the call first). On Write, Edit and Bash it finds the stamps the call adds, compares
76
134
  each with Claude Code's clock, and either refuses the call with the real time
@@ -1,5 +1,5 @@
1
1
  import type { EngineInterface, Register } from 'claude-code'
2
- import { dateOfFile, dayOf, judge, LIMITS, newStamps } from './stamps'
2
+ import { clockText, dateOfFile, dayOf, fillNow, gapText, judge, judgeName, LIMITS, newStamps, unfill } 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,10 +9,17 @@ 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.
16
+ // And before the agent writes anything: each prompt carries the clock (promptClock), the local
17
+ // weekday, date, time and zone, and how long ago the person's previous message came, so the
18
+ // agent has the real time instead of a guess.
19
+ // And so the agent need not type a time at all: ⟦NOW⟧, ⟦DATE⟧, ⟦TIME⟧ and ⟦NOW-ISO⟧ in what a
20
+ // call writes are filled from the clock as it lands (fill; fillNow says which spellings).
14
21
 
15
- type Options = { aheadMinutes?: unknown; staleMinutes?: unknown; staleDays?: unknown; staleNotes?: unknown }
22
+ type Options = { aheadMinutes?: unknown; staleMinutes?: unknown; staleDays?: unknown; staleNotes?: unknown; promptClock?: unknown; fill?: unknown }
16
23
  type Call = { tool: string; file_path?: string; content?: string; old_string?: string; new_string?: string; replace_all?: boolean; command?: string }
17
24
 
18
25
  const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
@@ -52,11 +59,12 @@ export const shellWrite = (command: string): { file: string; text: string } | un
52
59
  }
53
60
 
54
61
  /** 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 }> {
62
+ async function change($: EngineInterface, x: Call): Promise<{ file: string; before: string; after: string; created?: boolean }> {
56
63
  const none = { file: '', before: '', after: '' }
57
64
  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 }
65
+ const exists = await $.fs.exists(x.file_path)
66
+ const before = exists ? await $.fs.read(x.file_path) : ''
67
+ return { file: x.file_path, before, after: x.content, created: !exists }
60
68
  }
61
69
  if (x.tool === 'Edit' && typeof x.new_string === 'string' && typeof x.old_string === 'string' && x.file_path) {
62
70
  const before = (await $.fs.exists(x.file_path)) ? await $.fs.read(x.file_path) : ''
@@ -69,7 +77,11 @@ async function change($: EngineInterface, x: Call): Promise<{ file: string; befo
69
77
  }
70
78
  if (x.tool === 'Bash' && typeof x.command === 'string') {
71
79
  const w = shellWrite(x.command)
72
- return w ? { file: w.file, before: '', after: w.text } : none
80
+ if (!w) return none
81
+ // Whether the shell's file is new is only known when its path means one place: not under
82
+ // `~` (the engine does not expand it) and not after a `cd` in the same command.
83
+ const sure = !w.file.startsWith('~') && !/(?:^|[;&|\n]\s*)(?:cd|pushd)\s/.test(x.command)
84
+ return { file: w.file, before: '', after: w.text, created: sure && !(await $.fs.exists(w.file)) }
73
85
  }
74
86
  return none
75
87
  }
@@ -81,10 +93,40 @@ export const refusal = (vs: Verdict[]) =>
81
93
  `It is ${vs[0]!.real} now. Write the real date and time, or leave the stamp out. ` +
82
94
  `If a future date is meant (a deadline, a planned release), write the same text again and it goes through.`
83
95
 
96
+ export const nameRefusal = (file: string, n: { date: string; real: string }) =>
97
+ `date-check: the new file's name says ${n.date} (${file.split(/[/\\]/).pop()}), a day that has passed. ` +
98
+ `It is ${n.real} now. If the file is for today, name it for today. ` +
99
+ `If that date is meant (a record of that day), write the same file again and it goes through.`
100
+
84
101
  export const staleNote = (vs: Verdict[]) =>
85
102
  `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} older than now (${vs[0]!.real}). ` +
86
103
  `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
104
 
105
+ /** Prompts the person sent: typed, from Remote Control or Slack, or the SDK host's own turn. */
106
+ const PERSON = new Set(['composer', 'bridge', 'slack-ping', 'sdk'])
107
+
108
+ export const clockLine = (now: number, since?: number, fill = true) =>
109
+ `date-check: it is ${clockText(now)}.` +
110
+ (since === undefined ? '' : ` The person's previous message came ${gapText(now - since)} ago.`) +
111
+ (fill ? ' To record the moment a file is written, write ⟦NOW⟧ (date and time), ⟦DATE⟧ (also in a file name) or ⟦TIME⟧: the clock fills it in as the write lands.' : '')
112
+
113
+ /** The call with its placeholders filled from the clock (the text it writes, and a Write's or Edit's file path), and which ones were. */
114
+ const filledCall = (x: Call, now: number): { call: Call; filled: string[] } => {
115
+ const key = x.tool === 'Write' ? 'content' : x.tool === 'Edit' ? 'new_string' : 'command'
116
+ const call: Call = { ...x }
117
+ const filled = new Set<string>()
118
+ const v = x[key]
119
+ if (typeof v === 'string' && v.includes('⟦')) {
120
+ const f = fillNow(v, now, x.tool !== 'Bash')
121
+ if (f.filled.length) { call[key] = f.text; f.filled.forEach(t => filled.add(t)) }
122
+ }
123
+ if (x.tool !== 'Bash' && x.file_path?.includes('⟦')) {
124
+ const f = fillNow(x.file_path, now, false)
125
+ if (f.filled.length) { call.file_path = f.text; f.filled.forEach(t => filled.add(t)) }
126
+ }
127
+ return filled.size ? { call, filled: [...filled] } : { call: x, filled: [] }
128
+ }
129
+
88
130
  export const register: Register = (on, options: Options = {}) => {
89
131
  const limits: Limits = {
90
132
  aheadMinutes: num(options.aheadMinutes, LIMITS.aheadMinutes),
@@ -92,22 +134,67 @@ export const register: Register = (on, options: Options = {}) => {
92
134
  staleDays: num(options.staleDays, LIMITS.staleDays),
93
135
  }
94
136
  const notes = options.staleNotes !== false
137
+ const fill = options.fill !== false
95
138
  // A future stamp refused once, by file and line: written again after that refusal, it is
96
139
  // meant. Each refusal and each call's start take a number in order, so a call that began
97
140
  // before the refusal (two calls at once) is refused too.
98
141
  const meant = new Map<string, number>()
99
142
  let order = 0
100
143
 
144
+ if (options.promptClock !== false) {
145
+ // The person's prompts in the order they entered, each with its clock reading once it
146
+ // came back. A prompt measures its gap from the latest earlier one: not from a later one
147
+ // whose reading came back first (two at once), and not from one dropped beneath.
148
+ const sent: { at?: number; dropped?: boolean }[] = []
149
+ on('prompt.submit', async ($, e, next) => {
150
+ const me = PERSON.has(e.origin?.kind) ? { at: undefined as number | undefined, dropped: false } : undefined
151
+ const earlier = sent.slice()
152
+ if (me) sent.push(me)
153
+ if (sent.length > 50) sent.splice(0, sent.length - 50)
154
+ let line: string | undefined
155
+ try {
156
+ const now = await $.clock.now()
157
+ let since: number | undefined
158
+ if (me) {
159
+ const before = earlier.filter(x => !x.dropped)
160
+ // One still waiting for its reading entered with this one: under a minute.
161
+ since = before.some(x => x.at === undefined) ? now : before.length ? Math.max(...before.map(x => x.at!)) : undefined
162
+ me.at = now
163
+ }
164
+ line = clockLine(now, since, fill)
165
+ } catch {
166
+ // A failure of this mod never stops the prompt; a prompt with no reading is no one's previous one.
167
+ if (me) me.dropped = true
168
+ }
169
+ const done = await next(line ? { ...e, context: [...(e.context ?? []), line] } : e)
170
+ if (me && done.drop !== undefined) me.dropped = true
171
+ return done
172
+ })
173
+ }
174
+
101
175
  on('tool.call', async ($, e, next) => {
102
- const x = e as unknown as Call
176
+ let x = e as unknown as Call
103
177
  if (x.tool !== 'Write' && x.tool !== 'Edit' && x.tool !== 'Bash') return next(e)
104
178
  const began = ++order
179
+ let filled: string[] = []
180
+ if (fill) {
181
+ try {
182
+ const f = filledCall(x, await $.clock.now())
183
+ x = f.call
184
+ filled = f.filled
185
+ } catch {
186
+ // No reading: the call goes on as written.
187
+ }
188
+ }
189
+ const sent = (filled.length ? { ...e, ...x } : e) as typeof e
105
190
  let verdicts: Verdict[] = []
191
+ let name: ReturnType<typeof judgeName>
106
192
  let file = ''
107
193
  try {
108
194
  const now = await $.clock.now()
109
195
  const w = await change($, x)
110
196
  file = w.file
197
+ if (file && isText(file) && w.created) name = judgeName(file, now, limits)
111
198
  if (file && isText(file) && w.after) {
112
199
  // Bare times in a file named for a later day are a plan for that day, not stamps.
113
200
  const day = dateOfFile(file)
@@ -116,17 +203,27 @@ export const register: Register = (on, options: Options = {}) => {
116
203
  } catch {
117
204
  // A failure of this mod never stops the call.
118
205
  verdicts = []
206
+ name = undefined
119
207
  }
120
- const key = (v: Verdict) => `${file}\n${v.stamp.line}`
208
+ // Keyed by the line as the agent wrote it, so a retry with the same placeholder a minute later is the same line.
209
+ const key = (v: Verdict) => `${file}\n${unfill(v.stamp.line, filled)}`
121
210
  const future = verdicts.filter(v => v.kind === 'future')
122
211
  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) }
212
+ // By the file's name alone, so a retry spelled another way (relative, absolute) is the same file.
213
+ const nameKey = `(name)\n${file.split(/[/\\]/).pop()}`
214
+ const freshName = name && !((meant.get(nameKey) ?? Infinity) < began) ? name : undefined
215
+ if (fresh.length || freshName) {
216
+ // The first refusal's number is kept: an older call refused late must not move it past a retry.
217
+ const mark = (k: string) => { const n = ++order; meant.set(k, Math.min(meant.get(k) ?? Infinity, n)) }
218
+ for (const v of fresh) mark(key(v))
219
+ if (freshName) mark(nameKey)
220
+ return { deny: [...(freshName ? [nameRefusal(file, freshName)] : []), ...(fresh.length ? [refusal(fresh)] : [])].join('\n') }
126
221
  }
127
- const done = await next(e)
222
+ const done = await next(sent)
128
223
  if (done.deny !== undefined || done.isError) return done
129
224
  const said = [
225
+ ...(filled.length ? [`date-check: filled from the clock: ${filled.join(', ')}.`] : []),
226
+ ...(name ? [`date-check: kept the date ${name.date} in the new file's name as written the second time.`] : []),
130
227
  ...(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
228
  ...(notes && verdicts.some(v => v.kind === 'stale') ? [staleNote(verdicts.filter(v => v.kind === 'stale'))] : []),
132
229
  ]
package/hooks/stamps.ts CHANGED
@@ -164,6 +164,76 @@ export const nowText = (ms: number) => {
164
164
  return `${dayOf(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())} local time, ${zone}`
165
165
  }
166
166
 
167
+ /**
168
+ * Placeholders the agent writes instead of typing a time, filled from the clock as the call
169
+ * lands: `⟦NOW⟧` 2026-11-01 06:20, `⟦DATE⟧` 2026-11-01, `⟦TIME⟧` 06:20, `⟦NOW-ISO⟧`
170
+ * 2026-11-01T06:20:41-05:00, all local. One in code is kept as written (a README that shows
171
+ * the placeholder): a code span, a ``` or ~~~ fence (closed by the same mark, as long or
172
+ * longer), and, in a file (`indented`), an indented code line, as the judge reads code.
173
+ * Exactly these spellings: `⟦now⟧`, `[NOW]` and `⟦NOW ⟧` are text. ⟦NOW⟧ has a space in
174
+ * it: in a file name or an unquoted shell word, ⟦DATE⟧ is the one to use.
175
+ */
176
+ export const fillNow = (text: string, ms: number, indented = true): { text: string; filled: string[] } => {
177
+ const d = new Date(ms)
178
+ const o = -d.getTimezoneOffset()
179
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
180
+ const iso = `${dayOf(ms)}T${time}:${pad(d.getSeconds())}${o < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(o) / 60))}:${pad(Math.abs(o) % 60)}`
181
+ const value: Record<string, string> = { 'NOW-ISO': iso, NOW: `${dayOf(ms)} ${time}`, DATE: dayOf(ms), TIME: time }
182
+ const filled = new Set<string>()
183
+ let fence: string | undefined
184
+ const lines = text.split('\n').map(line => {
185
+ const mark = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
186
+ if (fence !== undefined) {
187
+ if (mark && mark[0] === fence[0] && mark.length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) fence = undefined
188
+ return line
189
+ }
190
+ if (mark && !(mark[0] === '`' && line.slice(line.indexOf(mark) + mark.length).includes('`'))) { fence = mark; return line }
191
+ if (indented && indentedCode(line)) return line
192
+ return line.replace(/(?<!`)(`+)(?!`)[^]*?(?<!`)\1(?!`)|⟦(NOW-ISO|NOW|DATE|TIME)⟧/g, (m, code: string | undefined, tok: string | undefined) => {
193
+ if (code !== undefined || !tok) return m
194
+ filled.add(`⟦${tok}⟧ ${value[tok]}`)
195
+ return value[tok]!
196
+ })
197
+ })
198
+ return { text: lines.join('\n'), filled: [...filled] }
199
+ }
200
+
201
+ /** A line as written before filling: each filled value back to its placeholder, longest first. */
202
+ export const unfill = (line: string, filled: string[]) =>
203
+ filled.map(f => [f.slice(0, f.indexOf('⟧') + 1), f.slice(f.indexOf('⟧') + 2)] as const)
204
+ .sort((x, y) => y[1].length - x[1].length)
205
+ .reduce((l, [tok, v]) => l.split(v).join(tok), line)
206
+
207
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
208
+ /** The zone's name where the runtime knows it (`America/New_York, EST`); '' where it does not. */
209
+ const zoneName = (ms: number) => {
210
+ try {
211
+ const iana = Intl.DateTimeFormat().resolvedOptions().timeZone
212
+ const short = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).formatToParts(new Date(ms)).find(p => p.type === 'timeZoneName')?.value
213
+ return [...new Set([iana, short && !/^GMT[+-]/.test(short) ? short : ''].filter(Boolean))].join(', ')
214
+ } catch {
215
+ return ''
216
+ }
217
+ }
218
+ /**
219
+ * The clock a prompt carries: weekday, date, local time and offset, and the zone's name:
220
+ * `Sunday 2026-11-01 05:58 local time, UTC-05:00 (America/New_York, EST)`.
221
+ */
222
+ export const clockText = (ms: number) => {
223
+ const name = zoneName(ms)
224
+ return `${DAYS[new Date(ms).getDay()]} ${nowText(ms)}${name ? ` (${name})` : ''}`
225
+ }
226
+ /** A span of time as a person says it: `under a minute`, `12 min`, `3 h 5 min`, `2 days 4 h`. */
227
+ export const gapText = (ms: number) => {
228
+ const m = Math.floor(Math.max(0, ms) / 60000)
229
+ if (m < 1) return 'under a minute'
230
+ if (m < 60) return `${m} min`
231
+ const h = Math.floor(m / 60)
232
+ if (h < 24) return m % 60 ? `${h} h ${m % 60} min` : `${h} h`
233
+ const d = Math.floor(h / 24)
234
+ return `${d} day${d === 1 ? '' : 's'}${h % 24 ? ` ${h % 24} h` : ''}`
235
+ }
236
+
167
237
  /** The local zone's UTC offset in minutes at a moment (New York in winter: -300). */
168
238
  export type ZoneAt = (ms: number) => number
169
239
  const localZone: ZoneAt = ms => -new Date(ms).getTimezoneOffset()
@@ -224,3 +294,16 @@ export const judge = (stamps: Stamp[], now: number, limits: Limits = LIMITS, zon
224
294
  }
225
295
  return out
226
296
  }
297
+
298
+ /**
299
+ * The date in a new file's name, when it is more than `staleDays` before today: the agent
300
+ * named the file for the day it guessed, often one near its training. A later day is a plan
301
+ * (an agenda for 2026-11-05), not judged. `undefined` when the name carries no date or the
302
+ * date can be today.
303
+ */
304
+ export const judgeName = (path: string, now: number, limits: Limits = LIMITS): { date: string; real: string } | undefined => {
305
+ const date = dateOfFile(path)
306
+ if (!date) return undefined
307
+ const v = judge([{ text: date, line: path, where: 'field', date }], now, limits)[0]
308
+ return v?.kind === 'stale' ? { date, real: v.real } : undefined
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlab-agent/date-check",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",