@nightlab-agent/date-check 0.1.3 → 0.1.5

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.3",
11
+ "version": "0.1.5",
12
12
  "category": "productivity"
13
13
  }
14
14
  ],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "date-check",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",
@@ -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,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 (2026-11-02)
4
+
5
+ - A future date written as a release heading is refused even when the line the edit removes
6
+ named that date in a sentence ("Release planned for 2026-11-05" edited into
7
+ "## [1.0.0] - 2026-11-05"). Before, any new stamp whose text appeared anywhere in a removed
8
+ line was taken as carried over; now only a removed line's own stamp is.
9
+ - A new file named for a past day is refused in each folder. Before, the "write it again"
10
+ memory was kept by the file name alone, so a refusal for `log/2025-06-12.md` let a
11
+ different new `journal/2025-06-12.md` through unasked.
12
+ - A future stamp refused in a Write and written again by the shell with a relative path
13
+ (`echo ... >> notes.md`) now goes through as the refusal promises. Before, the retry was
14
+ matched by the path as typed, so the relative spelling was refused a second time. Both
15
+ checks now join a relative path to the session's directory and resolve `.` and `..`; on
16
+ Windows, `E:\p\notes.md` and a relative `notes.md` from `E:\p` are one file. A relative
17
+ path after a `cd` in the same command (in parentheses too) is the same file only after the
18
+ same `cd`. Network paths (`\\srv\share`) match in any letter case, as drive paths do.
19
+ - A date moved out of a code block into the text is judged. Before, the line counted as one
20
+ the file already had, so a sample heading in a fence turned into a real heading dated in
21
+ the future went through.
22
+
23
+ ## 0.1.4 (2026-11-02)
24
+
25
+ - Every prompt now carries the clock, so Claude knows the time instead of guessing it: the
26
+ local weekday, date, time, UTC offset and zone name, and how long ago your previous message
27
+ came (`it is Sunday 2026-11-01 05:58 local time, UTC-05:00 (America/New_York, EST). The
28
+ person's previous message came 3 h 12 min ago.`). A background task's notification or
29
+ another plugin's prompt gets the time but does not count as your previous message. The
30
+ "clock on every prompt" setting turns it off. Before, Claude Code gave Claude the date only,
31
+ with no time and no zone.
32
+ - Claude can leave the time to the clock: `⟦NOW⟧` (date and time), `⟦DATE⟧`, `⟦TIME⟧` or
33
+ `⟦NOW-ISO⟧` in what it writes (a Write or an Edit and its file name, a shell command) is replaced with the
34
+ machine's local time as the call lands, and Claude is told what went in. One inside
35
+ backticks or a code block is kept as written. The prompt clock tells Claude about `⟦NOW⟧`, `⟦DATE⟧`
36
+ and `⟦TIME⟧`; the "fill placeholders" setting turns them off. The names follow a request in
37
+ MikalaiKryvusha/KAIF#170, which found that a clock in the prompt still left an agent writing
38
+ the minute after.
39
+
3
40
  ## 0.1.3 (2026-11-01)
4
41
 
5
42
  - A new Markdown or text file whose name carries a date more than a day before today
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
 
@@ -25,8 +33,36 @@ Seen on 2026-11-01 at 02:11 EST, same terminal. Asked to start a log line with
25
33
  the current time and not to run commands, Claude did not guess: it asked for
26
34
  the time. Then asked to add `- 00:40 backup started`, it wrote that line; the
27
35
  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.
36
+ back as `- 02:11 release went out`. Claude has been seen guessing a time on its
37
+ own (17:23, taken from a folder name, close enough to pass); date-check catching
38
+ a wrong guess Claude made on its own has not yet been seen on a screen.
39
+
40
+ ![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)
41
+
42
+ Seen on 2026-11-01 at 05:54 EST, same terminal, with the clock on every
43
+ prompt (new in 0.1.4). The same request as above, to
44
+ start a log line with the current time and not run commands: this time Claude
45
+ wrote `05:54 Release went out.` and said the time came from the session
46
+ context. Asked what day and time it was, which zone, and how long ago the
47
+ previous message came, it answered Sunday 1 November, 05:54, America/New_York
48
+ on Eastern Standard Time, under a minute ago.
49
+
50
+ - Each prompt carries one line Claude reads and you do not see: the local
51
+ weekday, date, time, UTC offset and zone name, and how long ago your
52
+ previous message came. A background task's notification gets the time but
53
+ does not count as your message. The "clock on every prompt" setting turns it off.
54
+ - Claude can leave the time to the clock: `⟦NOW⟧` (date and time), `⟦DATE⟧`,
55
+ `⟦TIME⟧` or `⟦NOW-ISO⟧` in what it writes (a file and its name, an edit, a shell command)
56
+ is replaced with the machine's local time as the write lands, and Claude is
57
+ told what went in. One inside backticks or a code block is kept as written.
58
+ `⟦NOW⟧` has a space in it, so in a file name use `⟦DATE⟧`. The prompt clock
59
+ tells Claude about `⟦NOW⟧`, `⟦DATE⟧` and `⟦TIME⟧`; the "fill placeholders" setting turns them off.
60
+
61
+ ![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)
62
+
63
+ Seen in a preview on 2026-11-01: asked to start a log line with the exact time
64
+ it is written, Claude wrote `06:14 The release went out.` and said the time
65
+ was filled in from the clock as the file was written.
30
66
 
31
67
  - A stamp more than 2 minutes in the future is refused, and the refusal tells
32
68
  Claude the real date and time. If the future date is meant (a deadline, a
@@ -66,7 +102,7 @@ yet been seen on a screen.
66
102
 
67
103
  Settings (asked when you enable the plugin): minutes ahead allowed (2), minutes
68
104
  behind before a note (30), days behind before a note (1), notes on old stamps
69
- (on).
105
+ (on), the clock on every prompt (on), fill placeholders (on).
70
106
 
71
107
  ![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
108
 
@@ -86,11 +122,14 @@ year old and offered to rename it to `daily-brief-2026-11-01.md`.
86
122
 
87
123
  ## What it writes
88
124
 
89
- Nothing. It reads the file a Write or an Edit is about to change, to judge only the new
90
- lines. Nothing leaves your machine through this mod: it makes no network calls.
125
+ No files. It adds one line beside each prompt for Claude to read (the clock, above), and it
126
+ 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.
91
127
 
92
128
  ## How it works
93
129
 
130
+ - One hook, `prompt.submit`, which Claude Code runs as each prompt enters: it reads Claude
131
+ Code's clock and attaches the clock line to the prompt. A failure of the mod lets the
132
+ prompt through unchanged.
94
133
  - One hook, `tool.call`, which Claude Code runs around each tool call (it can
95
134
  stop the call first). On Write, Edit and Bash it finds the stamps the call adds, compares
96
135
  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, judgeName, 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
@@ -13,8 +13,13 @@ import type { Limits, Verdict } from './stamps'
13
13
  // on 2026-11-01) is refused once with the real date, the same way; a later day is a plan.
14
14
  // Lines the file already had are never judged: only what this call adds. Only Markdown and
15
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).
16
21
 
17
- 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 }
18
23
  type Call = { tool: string; file_path?: string; content?: string; old_string?: string; new_string?: string; replace_all?: boolean; command?: string }
19
24
 
20
25
  const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : d)
@@ -53,6 +58,40 @@ export const shellWrite = (command: string): { file: string; text: string } | un
53
58
  return file === undefined ? undefined : { file, text: texts.join('\n') }
54
59
  }
55
60
 
61
+ /** The directory changes in a shell command (`cd a`, `(pushd b`): after one, a relative path means no one place. */
62
+ const dirMoves = (command: string) =>
63
+ [...command.matchAll(/(?:^|[;&|\n({]\s*)(cd|pushd)\s+([^;&|\n)}]*)/g)].map(m => `${m[1]} ${m[2]!.trim()}`)
64
+ const movesDir = (command: string) => dirMoves(command).length > 0
65
+
66
+ /** The file as one spelling, for remembering what was refused: a relative path joined to the
67
+ * session's directory, `.` and `..` resolved, one separator, a drive letter's path in lower
68
+ * case, a network path in lower case. A relative path after a `cd` in the same command is kept
69
+ * with the `cd`s before it, so it is one file only with the same moves; `~` and a failed reading
70
+ * stay as written. */
71
+ async function placed($: EngineInterface, file: string, moves: string[] = []): Promise<string> {
72
+ if (!file || file.startsWith('~')) return file
73
+ const absolute = /^(?:[\\/]|[A-Za-z]:[\\/])/.test(file)
74
+ if (!absolute && moves.length) return `${moves.join('; ')}; ${file}`
75
+ let full = file
76
+ if (!absolute) {
77
+ try {
78
+ full = `${await $.session.cwd()}/${file}`
79
+ } catch {
80
+ return file
81
+ }
82
+ }
83
+ const drive = /^[A-Za-z]:/.exec(full)?.[0] ?? ''
84
+ const parts: string[] = []
85
+ for (const part of full.slice(drive.length).split(/[\\/]+/)) {
86
+ if (part === '' || part === '.') continue
87
+ if (part === '..') parts.pop()
88
+ else parts.push(part)
89
+ }
90
+ const network = /^[\\/]{2}/.test(full)
91
+ const one = `${network ? '//' : drive}/${parts.join('/')}`.replace(/^\/\/\//, '//')
92
+ return drive || network ? one.toLowerCase() : one
93
+ }
94
+
56
95
  /** What a call changes in which file: the file's text before and after it. */
57
96
  async function change($: EngineInterface, x: Call): Promise<{ file: string; before: string; after: string; created?: boolean }> {
58
97
  const none = { file: '', before: '', after: '' }
@@ -75,7 +114,7 @@ async function change($: EngineInterface, x: Call): Promise<{ file: string; befo
75
114
  if (!w) return none
76
115
  // Whether the shell's file is new is only known when its path means one place: not under
77
116
  // `~` (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)
117
+ const sure = !w.file.startsWith('~') && !movesDir(x.command)
79
118
  return { file: w.file, before: '', after: w.text, created: sure && !(await $.fs.exists(w.file)) }
80
119
  }
81
120
  return none
@@ -97,6 +136,31 @@ export const staleNote = (vs: Verdict[]) =>
97
136
  `date-check: ${vs.map(quote).join('; ')} ${vs.length === 1 ? 'is' : 'are'} older than now (${vs[0]!.real}). ` +
98
137
  `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'}.`
99
138
 
139
+ /** Prompts the person sent: typed, from Remote Control or Slack, or the SDK host's own turn. */
140
+ const PERSON = new Set(['composer', 'bridge', 'slack-ping', 'sdk'])
141
+
142
+ export const clockLine = (now: number, since?: number, fill = true) =>
143
+ `date-check: it is ${clockText(now)}.` +
144
+ (since === undefined ? '' : ` The person's previous message came ${gapText(now - since)} ago.`) +
145
+ (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.' : '')
146
+
147
+ /** 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. */
148
+ const filledCall = (x: Call, now: number): { call: Call; filled: string[] } => {
149
+ const key = x.tool === 'Write' ? 'content' : x.tool === 'Edit' ? 'new_string' : 'command'
150
+ const call: Call = { ...x }
151
+ const filled = new Set<string>()
152
+ const v = x[key]
153
+ if (typeof v === 'string' && v.includes('⟦')) {
154
+ const f = fillNow(v, now, x.tool !== 'Bash')
155
+ if (f.filled.length) { call[key] = f.text; f.filled.forEach(t => filled.add(t)) }
156
+ }
157
+ if (x.tool !== 'Bash' && x.file_path?.includes('⟦')) {
158
+ const f = fillNow(x.file_path, now, false)
159
+ if (f.filled.length) { call.file_path = f.text; f.filled.forEach(t => filled.add(t)) }
160
+ }
161
+ return filled.size ? { call, filled: [...filled] } : { call: x, filled: [] }
162
+ }
163
+
100
164
  export const register: Register = (on, options: Options = {}) => {
101
165
  const limits: Limits = {
102
166
  aheadMinutes: num(options.aheadMinutes, LIMITS.aheadMinutes),
@@ -104,16 +168,59 @@ export const register: Register = (on, options: Options = {}) => {
104
168
  staleDays: num(options.staleDays, LIMITS.staleDays),
105
169
  }
106
170
  const notes = options.staleNotes !== false
171
+ const fill = options.fill !== false
107
172
  // A future stamp refused once, by file and line: written again after that refusal, it is
108
173
  // meant. Each refusal and each call's start take a number in order, so a call that began
109
174
  // before the refusal (two calls at once) is refused too.
110
175
  const meant = new Map<string, number>()
111
176
  let order = 0
112
177
 
178
+ if (options.promptClock !== false) {
179
+ // The person's prompts in the order they entered, each with its clock reading once it
180
+ // came back. A prompt measures its gap from the latest earlier one: not from a later one
181
+ // whose reading came back first (two at once), and not from one dropped beneath.
182
+ const sent: { at?: number; dropped?: boolean }[] = []
183
+ on('prompt.submit', async ($, e, next) => {
184
+ const me = PERSON.has(e.origin?.kind) ? { at: undefined as number | undefined, dropped: false } : undefined
185
+ const earlier = sent.slice()
186
+ if (me) sent.push(me)
187
+ if (sent.length > 50) sent.splice(0, sent.length - 50)
188
+ let line: string | undefined
189
+ try {
190
+ const now = await $.clock.now()
191
+ let since: number | undefined
192
+ if (me) {
193
+ const before = earlier.filter(x => !x.dropped)
194
+ // One still waiting for its reading entered with this one: under a minute.
195
+ since = before.some(x => x.at === undefined) ? now : before.length ? Math.max(...before.map(x => x.at!)) : undefined
196
+ me.at = now
197
+ }
198
+ line = clockLine(now, since, fill)
199
+ } catch {
200
+ // A failure of this mod never stops the prompt; a prompt with no reading is no one's previous one.
201
+ if (me) me.dropped = true
202
+ }
203
+ const done = await next(line ? { ...e, context: [...(e.context ?? []), line] } : e)
204
+ if (me && done.drop !== undefined) me.dropped = true
205
+ return done
206
+ })
207
+ }
208
+
113
209
  on('tool.call', async ($, e, next) => {
114
- const x = e as unknown as Call
210
+ let x = e as unknown as Call
115
211
  if (x.tool !== 'Write' && x.tool !== 'Edit' && x.tool !== 'Bash') return next(e)
116
212
  const began = ++order
213
+ let filled: string[] = []
214
+ if (fill) {
215
+ try {
216
+ const f = filledCall(x, await $.clock.now())
217
+ x = f.call
218
+ filled = f.filled
219
+ } catch {
220
+ // No reading: the call goes on as written.
221
+ }
222
+ }
223
+ const sent = (filled.length ? { ...e, ...x } : e) as typeof e
117
224
  let verdicts: Verdict[] = []
118
225
  let name: ReturnType<typeof judgeName>
119
226
  let file = ''
@@ -132,11 +239,14 @@ export const register: Register = (on, options: Options = {}) => {
132
239
  verdicts = []
133
240
  name = undefined
134
241
  }
135
- const key = (v: Verdict) => `${file}\n${v.stamp.line}`
242
+ // Keyed by the line as the agent wrote it, so a retry with the same placeholder a minute later is the same line.
243
+ const at = await placed($, file, x.tool === 'Bash' && typeof x.command === 'string' ? dirMoves(x.command) : [])
244
+ const key = (v: Verdict) => `${at}\n${unfill(v.stamp.line, filled)}`
136
245
  const future = verdicts.filter(v => v.kind === 'future')
137
246
  const fresh = future.filter(v => !((meant.get(key(v)) ?? Infinity) < began))
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()}`
247
+ // By the placed path, so a retry spelled another way (relative, absolute) is the same file
248
+ // and a file of the same name in another folder is not.
249
+ const nameKey = `(name)\n${at}`
140
250
  const freshName = name && !((meant.get(nameKey) ?? Infinity) < began) ? name : undefined
141
251
  if (fresh.length || freshName) {
142
252
  // The first refusal's number is kept: an older call refused late must not move it past a retry.
@@ -145,9 +255,10 @@ export const register: Register = (on, options: Options = {}) => {
145
255
  if (freshName) mark(nameKey)
146
256
  return { deny: [...(freshName ? [nameRefusal(file, freshName)] : []), ...(fresh.length ? [refusal(fresh)] : [])].join('\n') }
147
257
  }
148
- const done = await next(e)
258
+ const done = await next(sent)
149
259
  if (done.deny !== undefined || done.isError) return done
150
260
  const said = [
261
+ ...(filled.length ? [`date-check: filled from the clock: ${filled.join(', ')}.`] : []),
151
262
  ...(name ? [`date-check: kept the date ${name.date} in the new file's name as written the second time.`] : []),
152
263
  ...(future.length ? [`date-check: kept ${future.map(v => `"${v.stamp.text}"`).join(', ')} as written the second time (a date in the future on purpose).`] : []),
153
264
  ...(notes && verdicts.some(v => v.kind === 'stale') ? [staleNote(verdicts.filter(v => v.kind === 'stale'))] : []),
package/hooks/stamps.ts CHANGED
@@ -106,27 +106,64 @@ const lineStamp = (line: string, fileDate?: string): Stamp | undefined => {
106
106
  return undefined
107
107
  }
108
108
 
109
+ /** For each line, whether it sits in a code fence that is closed (a fence left open to the end is not counted). */
110
+ const closedFences = (lines: string[]): boolean[] => {
111
+ const inside: boolean[] = []
112
+ let open = -1
113
+ for (const [i, l] of lines.entries()) {
114
+ if (/^\s*(```|~~~)/.test(l)) open = open < 0 ? i : -1
115
+ inside.push(open >= 0 || (/^\s*(```|~~~)/.test(l) && open < 0))
116
+ }
117
+ if (open >= 0) for (let i = open; i < lines.length; i++) inside[i] = false
118
+ return inside
119
+ }
120
+
109
121
  /**
110
122
  * The stamps a change adds: the stamps on lines of `after` that `before` did not have, read
111
123
  * in the whole of `after` (so a line inside a code block the file already had is seen as
112
124
  * code). A stamp whose text was on a line the change removes is carried over, not added.
113
125
  */
114
126
  export const newStamps = (before: string, after: string, fileDate?: string): Stamp[] => {
127
+ // A line is the same line only in the same place: inside a closed code fence or out of one,
128
+ // so a sample moved out of a fence into the text is a new line, and closing a fence left open
129
+ // does not make every line under it new.
115
130
  const had = new Map<string, number>()
116
- for (const l of before.split(/\r?\n/)) had.set(l, (had.get(l) ?? 0) + 1)
131
+ const beforeIn = closedFences(before.split(/\r?\n/))
132
+ before.split(/\r?\n/).forEach((l, i) => {
133
+ const k = `${beforeIn[i] ? '`' : ' '}${l}`
134
+ had.set(k, (had.get(k) ?? 0) + 1)
135
+ })
117
136
  const out: Stamp[] = []
137
+ const kept: string[] = []
138
+ const lines = after.split(/\r?\n/)
139
+ const afterIn = closedFences(lines)
118
140
  let fence = false
119
- for (const line of after.split(/\r?\n/)) {
120
- const n = had.get(line) ?? 0
141
+ for (const [i, line] of lines.entries()) {
142
+ const isFence = /^\s*(```|~~~)/.test(line)
143
+ if (isFence) fence = !fence
144
+ const k = `${afterIn[i] ? '`' : ' '}${line}`
145
+ const n = had.get(k) ?? 0
121
146
  const isNew = n === 0
122
- if (!isNew) had.set(line, n - 1)
123
- if (/^\s*(```|~~~)/.test(line)) { fence = !fence; continue }
124
- if (fence || !isNew || indentedCode(line)) continue
147
+ if (!isNew) had.set(k, n - 1)
148
+ if (isFence) continue
149
+ if (fence || indentedCode(line)) continue
125
150
  const s = lineStamp(line, fileDate)
126
- if (s) out.push(s)
151
+ if (s) (isNew ? out.push(s) : kept.push(s.text))
152
+ }
153
+ // Only a stamp the old text itself had carries over, read in the old text's own context (a
154
+ // date named in a removed sentence or code block is not one): the old text's stamps less those
155
+ // still on its unchanged lines.
156
+ const carried = new Map<string, number>()
157
+ if (before) {
158
+ for (const s of newStamps('', before, fileDate)) carried.set(s.text, (carried.get(s.text) ?? 0) + 1)
159
+ for (const s of kept) carried.set(s, (carried.get(s) ?? 0) - 1)
127
160
  }
128
- const removed = [...had.entries()].filter(([, n]) => n > 0).map(([l]) => l).join('\n')
129
- return out.filter(s => !removed.includes(s.text))
161
+ return out.filter(s => {
162
+ const n = carried.get(s.text) ?? 0
163
+ if (n <= 0) return true
164
+ carried.set(s.text, n - 1)
165
+ return false
166
+ })
130
167
  }
131
168
 
132
169
  /** The stamps in some lines of text. */
@@ -164,6 +201,76 @@ export const nowText = (ms: number) => {
164
201
  return `${dayOf(ms)} ${pad(d.getHours())}:${pad(d.getMinutes())} local time, ${zone}`
165
202
  }
166
203
 
204
+ /**
205
+ * Placeholders the agent writes instead of typing a time, filled from the clock as the call
206
+ * lands: `⟦NOW⟧` 2026-11-01 06:20, `⟦DATE⟧` 2026-11-01, `⟦TIME⟧` 06:20, `⟦NOW-ISO⟧`
207
+ * 2026-11-01T06:20:41-05:00, all local. One in code is kept as written (a README that shows
208
+ * the placeholder): a code span, a ``` or ~~~ fence (closed by the same mark, as long or
209
+ * longer), and, in a file (`indented`), an indented code line, as the judge reads code.
210
+ * Exactly these spellings: `⟦now⟧`, `[NOW]` and `⟦NOW ⟧` are text. ⟦NOW⟧ has a space in
211
+ * it: in a file name or an unquoted shell word, ⟦DATE⟧ is the one to use.
212
+ */
213
+ export const fillNow = (text: string, ms: number, indented = true): { text: string; filled: string[] } => {
214
+ const d = new Date(ms)
215
+ const o = -d.getTimezoneOffset()
216
+ const time = `${pad(d.getHours())}:${pad(d.getMinutes())}`
217
+ const iso = `${dayOf(ms)}T${time}:${pad(d.getSeconds())}${o < 0 ? '-' : '+'}${pad(Math.floor(Math.abs(o) / 60))}:${pad(Math.abs(o) % 60)}`
218
+ const value: Record<string, string> = { 'NOW-ISO': iso, NOW: `${dayOf(ms)} ${time}`, DATE: dayOf(ms), TIME: time }
219
+ const filled = new Set<string>()
220
+ let fence: string | undefined
221
+ const lines = text.split('\n').map(line => {
222
+ const mark = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
223
+ if (fence !== undefined) {
224
+ if (mark && mark[0] === fence[0] && mark.length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) fence = undefined
225
+ return line
226
+ }
227
+ if (mark && !(mark[0] === '`' && line.slice(line.indexOf(mark) + mark.length).includes('`'))) { fence = mark; return line }
228
+ if (indented && indentedCode(line)) return line
229
+ return line.replace(/(?<!`)(`+)(?!`)[^]*?(?<!`)\1(?!`)|⟦(NOW-ISO|NOW|DATE|TIME)⟧/g, (m, code: string | undefined, tok: string | undefined) => {
230
+ if (code !== undefined || !tok) return m
231
+ filled.add(`⟦${tok}⟧ ${value[tok]}`)
232
+ return value[tok]!
233
+ })
234
+ })
235
+ return { text: lines.join('\n'), filled: [...filled] }
236
+ }
237
+
238
+ /** A line as written before filling: each filled value back to its placeholder, longest first. */
239
+ export const unfill = (line: string, filled: string[]) =>
240
+ filled.map(f => [f.slice(0, f.indexOf('⟧') + 1), f.slice(f.indexOf('⟧') + 2)] as const)
241
+ .sort((x, y) => y[1].length - x[1].length)
242
+ .reduce((l, [tok, v]) => l.split(v).join(tok), line)
243
+
244
+ const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
245
+ /** The zone's name where the runtime knows it (`America/New_York, EST`); '' where it does not. */
246
+ const zoneName = (ms: number) => {
247
+ try {
248
+ const iana = Intl.DateTimeFormat().resolvedOptions().timeZone
249
+ const short = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).formatToParts(new Date(ms)).find(p => p.type === 'timeZoneName')?.value
250
+ return [...new Set([iana, short && !/^GMT[+-]/.test(short) ? short : ''].filter(Boolean))].join(', ')
251
+ } catch {
252
+ return ''
253
+ }
254
+ }
255
+ /**
256
+ * The clock a prompt carries: weekday, date, local time and offset, and the zone's name:
257
+ * `Sunday 2026-11-01 05:58 local time, UTC-05:00 (America/New_York, EST)`.
258
+ */
259
+ export const clockText = (ms: number) => {
260
+ const name = zoneName(ms)
261
+ return `${DAYS[new Date(ms).getDay()]} ${nowText(ms)}${name ? ` (${name})` : ''}`
262
+ }
263
+ /** A span of time as a person says it: `under a minute`, `12 min`, `3 h 5 min`, `2 days 4 h`. */
264
+ export const gapText = (ms: number) => {
265
+ const m = Math.floor(Math.max(0, ms) / 60000)
266
+ if (m < 1) return 'under a minute'
267
+ if (m < 60) return `${m} min`
268
+ const h = Math.floor(m / 60)
269
+ if (h < 24) return m % 60 ? `${h} h ${m % 60} min` : `${h} h`
270
+ const d = Math.floor(h / 24)
271
+ return `${d} day${d === 1 ? '' : 's'}${h % 24 ? ` ${h % 24} h` : ''}`
272
+ }
273
+
167
274
  /** The local zone's UTC offset in minutes at a moment (New York in winter: -300). */
168
275
  export type ZoneAt = (ms: number) => number
169
276
  const localZone: ZoneAt = ms => -new Date(ms).getTimezoneOffset()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nightlab-agent/date-check",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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",