A band above the prompt that shows what is due, how soon, and what to do next.

A Claude Code mod that keeps your deadlines above the prompt: what is due, how much time is left, and the one thing to do next.
🔥 Atlas demo ────────────━━━━ 2d finish the product import
👀 Tax form ────────━━━━━━━━ 5d print and sign it
😌 Reel clips ────━━━━━━━━━━━━ 9d pick five candidates
Atlas demo is due in 2d, and you are in reel-clips
Each bar is a fuse. It burns from the left: the dim part is the time gone, the colored part the time you still have.
A countdown tells you how late you are. due-band also tells you what to do about it and whether you are doing it:
~/.claude/due.json, readable, editable, yours.In a Claude Code terminal session:
/plugin install due-band --marketplace apoetstearsofporcelain/due-band
Answer y to add the marketplace, pick a scope with Enter, and the band is live in that session.
/due add Atlas demo 21 oct finish the product import
/due next atlas send the invoice
/due here atlas
/due done atlas
| Command | What it does |
|---|---|
/due or /due list | Every deadline with its id, soonest first |
/due add <name> <date> [next step] | Adds one, starting today |
/due next <id> <text> | Sets its next step |
/due here <id> | Ties the folder you are in to it |
/due done <id> | Closes it |
/due rm <id> | Removes it |
/due hide, /due show | Hides the band for this session, and brings it back |
An id is the name in lower case with dashes (Atlas demo is atlas-demo), and the start of one is enough: /due done at.
Dates can be typed as 2026-10-21, 21 oct, oct 21, +9d, fri, today or tomorrow, each with a time after it if you need one: 21 oct 18:00. A weekday means the coming one, so fri typed on a Friday is a week away.
Or skip the command and tell Claude. It can add a deadline, change a next step and close one.
| Mark | Time left | Color |
|---|---|---|
| 😌 | 7 days or more | green |
| 👀 | 3 to 7 days | amber |
| 🔥 | under 3 days | red |
| 💀 | overdue | red |
Colors are your theme's own, so the band follows light and dark themes. It lists three deadlines and counts the rest (+2 more). In a narrow terminal it folds into one line: Atlas demo 2d · Reel clips 9d.
A toast marks the moment a deadline goes under 3 days, under 1 day, and overdue. Each is said once.
~/.claude/due.json. Edit it by hand whenever you like; the band reads it again within a minute.
{
"deadlines": [
{
"id": "atlas",
"name": "Atlas demo",
"due": "2026-10-21",
"start": "2026-10-07",
"next": "finish the product import",
"paths": ["C:/work/atlas"],
"done": false
}
]
}
Only name and due are required. due and start are YYYY-MM-DD, or YYYY-MM-DD HH:MM; a date alone means the end of that day. Without a start the fuse has nothing to measure from and shows a dashed track. Fields the mod does not know are kept when it writes.
In /config, under due-band:
| Setting | Default | What it does |
|---|---|---|
| Deadlines file | empty | Another path for the file; ~ is your home folder |
| Row marks | emoji | plain draws • and ! instead, for terminals that measure emoji wrong |
| Rows shown | 3 | How many deadlines before +N more |
| Wrong-project nudge, in days | 3 | How near a deadline must be; 0 turns the nudge off |
| Toasts | on | The threshold toasts and the nudge's toast |
| Tell Claude | on | What Claude is told, and the three tools it gets |
Built and tested on Claude Code 2.1.296, on the terminal and desktop surfaces. The mods API is early access and can change between releases; if the band stops loading after an update, please open an issue with your version (claude --version).
git clone https://github.com/apoetstearsofporcelain/due-band
claude --plugin-dir ./due-band
The folder is watched: saving a file reloads the mod.
claude plugin validate ./due-band
claude plugin test ./due-band
The logic lives in pure functions (hooks/deadlines.ts, command.ts, band.ts, smart.ts, settings.ts), each with its tests beside it. hooks/register.tsx is the only file that touches the engine.
MIT
hooks/register.tsx 286 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ThemeKey } from 'claude-code'
3
4import { layout } from './band'
5import type { Tone } from './band'
6import { add, run } from './command'
7import type { Outcome } from './command'
8import { EMPTY, filePath, parse } from './deadlines'
9import { settingsOf } from './settings'
10import type { Settings } from './settings'
11import { brief, nudge, readToasted, thresholds } from './smart'
12
13const REFRESH_MS = 60_000
14const DAY_MS = 24 * 60 * 60_000
15
16// Theme keys, so the band follows the person's theme, light or dark.
17const COLOR: Record<Tone, ThemeKey> = {
18 calm: 'success',
19 soon: 'warning',
20 urgent: 'error',
21 overdue: 'error',
22}
23
24const ID = { type: 'string', description: "The deadline's id, or its start." }
25
26const loaded = atom({ plugin: 'due-band', key: 'loaded' } as const, EMPTY)
27const now = atom({ plugin: 'due-band', key: 'now' } as const, 0)
28const isHidden = atom({ plugin: 'due-band', key: 'isHidden' } as const, false)
29const cwd = atom({ plugin: 'due-band', key: 'cwd' } as const, '')
30const nudged = atom({ plugin: 'due-band', key: 'nudged' } as const, '')
31
32const pathOf = async ($: EngineInterface, { file }: Settings) =>
33 filePath((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')), file)
34
35const textOf = (value: unknown) => (typeof value === 'string' ? value : '')
36
37// What Claude reads when one of the tools fails: nothing was written.
38const failed = () => ({ deny: 'due-band could not update due.json.' })
39
40// Reads the file again each time, so an edit by hand shows within a minute.
41// A missing file is no deadlines, not an error.
42const refresh = async ($: EngineInterface, settings: Settings) => {
43 const text = await $.fs.read(await pathOf($, settings)).catch(() => null)
44 const at = await $.clock.now()
45 const here = await $.session.cwd().catch(() => '')
46 const { deadlines } = text === null ? EMPTY : parse(text)
47
48 await update($, loaded, () => (text === null ? EMPTY : parse(text)))
49 await update($, now, () => at)
50 await update($, cwd, () => here)
51
52 // Each threshold a deadline crosses is said once, across sessions.
53 const before = readToasted(await $.store.get('toasted').catch(() => null))
54 const { toasts, seen } = thresholds(deadlines, at, before)
55
56 if (JSON.stringify(seen) !== JSON.stringify(before)) {
57 await $.store.set('toasted', seen).catch(() => undefined)
58 }
59
60 // The wrong-project line stays in the band while it holds; the toast is once.
61 const line = nudge(deadlines, at, here, settings.nudgeDays * DAY_MS)
62
63 if (line !== null && line !== (await read($, nudged))) {
64 toasts.push(line)
65 await update($, nudged, () => line)
66 }
67
68 if (settings.toasts && toasts.length > 0) {
69 $.ui.toast(toasts.join('\n'), { timeoutMs: 8000 })
70 }
71}
72
73// One run of the command or of a tool: reads the file, writes what changed.
74const apply = async (
75 $: EngineInterface,
76 settings: Settings,
77 change: (text: string | null, at: number) => Outcome,
78) => {
79 const path = await pathOf($, settings)
80 const text = await $.fs.read(path).catch(() => null)
81 const outcome = change(text, await $.clock.now())
82
83 if (outcome.file !== null) {
84 await $.fs.write(path, outcome.file)
85 await refresh($, settings)
86 }
87
88 return outcome
89}
90
91export const register: Register = (on, options) => {
92 const settings = settingsOf(options)
93
94 on('session.start', async ($, e, next) => {
95 await $.command.register({
96 name: 'due',
97 description: 'Add, update and close deadlines shown above the prompt',
98 argumentHint: 'add | next | done | here | rm | list | hide | show',
99 })
100 if (settings.tellClaude) {
101 await $.tool.register({
102 name: 'set_next_step',
103 description:
104 "Set the next step of one of the person's deadlines, shown above their prompt. Use it when they finish a step or say what comes next.",
105 inputSchema: {
106 type: 'object',
107 properties: {
108 id: ID,
109 next: { type: 'string', description: 'The next step, one line.' },
110 },
111 required: ['id', 'next'],
112 },
113 isDeferred: false,
114 })
115 await $.tool.register({
116 name: 'mark_done',
117 description:
118 "Close one of the person's deadlines once they say it is delivered or no longer needed.",
119 inputSchema: {
120 type: 'object',
121 properties: { id: ID },
122 required: ['id'],
123 },
124 isDeferred: false,
125 })
126 await $.tool.register({
127 name: 'add_deadline',
128 description:
129 'Add a deadline the person mentions ("the deck is due Friday") to the list shown above their prompt.',
130 inputSchema: {
131 type: 'object',
132 properties: {
133 name: { type: 'string', description: 'A short name: "Fez deck".' },
134 due: {
135 type: 'string',
136 description:
137 'When it is due: 2026-10-21, 2026-10-21 18:00, 21 oct, +9d, fri, today or tomorrow.',
138 },
139 next: { type: 'string', description: 'The next step, if known.' },
140 },
141 required: ['name', 'due'],
142 },
143 isDeferred: false,
144 })
145 }
146
147 await refresh($, settings)
148 $.clock.every(REFRESH_MS, () => void refresh($, settings))
149
150 return next(e)
151 })
152
153 on('command.run', { command: 'due' }, async ($, e) => {
154 const here = await $.session.cwd().catch(() => '')
155 const outcome = await apply($, settings, (text, at) =>
156 run(e.args, text, at, here),
157 )
158
159 if (outcome.isHidden !== null) {
160 const next = outcome.isHidden
161 await update($, isHidden, () => next)
162 }
163
164 return { text: outcome.text }
165 })
166
167 on('tool.call', { tool: 'mcp__due-band__set_next_step' }, async ($, e) => {
168 const args = `next ${textOf(e.id)} ${textOf(e.next)}`
169
170 const outcome = await apply($, settings, (text, at) => run(args, text, at))
171
172 return { result: outcome.text }
173 }).catch(failed)
174
175 on('tool.call', { tool: 'mcp__due-band__mark_done' }, async ($, e) => {
176 const args = `done ${textOf(e.id)}`
177
178 const outcome = await apply($, settings, (text, at) => run(args, text, at))
179
180 return { result: outcome.text }
181 }).catch(failed)
182
183 on('tool.call', { tool: 'mcp__due-band__add_deadline' }, async ($, e) => {
184 const outcome = await apply($, settings, (text, at) =>
185 add(textOf(e.name), textOf(e.due), textOf(e.next), text, at),
186 )
187
188 return { result: outcome.text }
189 }).catch(failed)
190
191 on('prompt.compose', async ($, e, next) => {
192 const composed = await next(e)
193 const { deadlines } = await read($, loaded)
194 const text = settings.tellClaude
195 ? brief(deadlines, await read($, now), await read($, cwd))
196 : null
197
198 return text === null
199 ? composed
200 : {
201 sections: [
202 ...composed.sections,
203 { id: 'due-band:deadlines', text, scope: 'session' as const },
204 ],
205 }
206 })
207
208 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
209 const { deadlines, error } = await read($, loaded)
210 const at = await read($, now)
211 const here = await read($, cwd)
212 const band = layout(deadlines, at, e.props.bodyColumns, {
213 marks: settings.marks,
214 cwd: here,
215 maxRows: settings.maxRows,
216 nudgeMs: settings.nudgeDays * DAY_MS,
217 })
218 const isQuiet =
219 e.props.hasSurvey ||
220 (await read($, isHidden)) ||
221 (error === null && band.rows.length === 0)
222
223 if (isQuiet) {
224 return next(e)
225 }
226
227 const { Box, Text } = $.ui.resolve(e)
228
229 if (error !== null) {
230 return (
231 <Box>
232 <Text dimColor>due.json: {error}</Text>
233 </Box>
234 )
235 }
236
237 const more = band.more === 0 ? '' : `+${band.more} more`
238
239 if (band.isSummary) {
240 return (
241 <Box>
242 <Text wrap="truncate-end">
243 {band.rows.map((row, index) => (
244 <Text>
245 <Text dimColor>{index === 0 ? '' : ' · '}</Text>
246 <Text bold={row.isHere}>{row.name}</Text>{' '}
247 <Text color={COLOR[row.tone]} bold>
248 {row.left}
249 </Text>
250 </Text>
251 ))}
252 <Text dimColor>{more === '' ? '' : ` · ${more}`}</Text>
253 </Text>
254 </Box>
255 )
256 }
257
258 return (
259 <Box flexDirection="column">
260 {band.rows.map(row => (
261 <Text wrap="truncate-end">
262 <Text color={COLOR[row.tone]} bold={row.tone === 'overdue'}>
263 {row.mark}
264 </Text>{' '}
265 <Text bold={row.isHere}>{row.name}</Text>
266 {' '}
267 <Text dimColor>{row.spent}</Text>
268 <Text color={COLOR[row.tone]}>{row.ahead}</Text>
269 {' '}
270 <Text color={COLOR[row.tone]} bold>
271 {row.left}
272 </Text>
273 <Text dimColor>{row.next === '' ? '' : ` ${row.next}`}</Text>
274 </Text>
275 ))}
276 {more !== '' && <Text dimColor>{` ${more}`}</Text>}
277 {band.nudge !== null && (
278 <Text color="warning" wrap="truncate-end">
279 {band.nudge}
280 </Text>
281 )}
282 </Box>
283 )
284 })
285}
286hooks/band.ts 216 lines1import type { Deadline } from '../types'
2
3import { open, timeLeft } from './deadlines'
4import { isInside, nudge, NUDGE_MS } from './smart'
5
6const DAY = 24 * 60 * 60_000
7
8/** Below this many cells the band is one line of names and times. */
9const NARROW = 40
10
11/** How close a deadline is: 7 days and up, 3 to 7, under 3, and past. */
12export type Tone = 'calm' | 'soon' | 'urgent' | 'overdue'
13
14/** What leads a row: a face for how the time left feels, or a plain mark. */
15export type Marks = 'emoji' | 'plain'
16
17// Each set is one width throughout, so the names after it line up: an emoji
18// takes two cells, and is two units long in a string as well.
19const MARK: Record<Marks, Record<Tone, string>> = {
20 emoji: { calm: '😌', soon: '👀', urgent: '🔥', overdue: '💀' },
21 plain: { calm: '•', soon: '•', urgent: '•', overdue: '!' },
22}
23
24/**
25 * One deadline as the band draws it, every part already cut and padded to
26 * its column: the drawing only picks colors.
27 */
28export type Row = {
29 id: string
30 tone: Tone
31 mark: string
32 name: string
33 spent: string
34 ahead: string
35 left: string
36 next: string
37 /** The session's folder is one of this deadline's `paths`. */
38 isHere: boolean
39}
40
41/** What the person's settings and the session decide about the band. */
42export type Look = {
43 marks: Marks
44 /** The session's working folder; `''` when it is not known. */
45 cwd: string
46 /** How many deadlines are listed before `+N more`. */
47 maxRows: number
48 /** How near a deadline is before working elsewhere is said; 0 for never. */
49 nudgeMs: number
50}
51
52export const LOOK: Look = {
53 marks: 'emoji',
54 cwd: '',
55 maxRows: 3,
56 nudgeMs: NUDGE_MS,
57}
58
59/**
60 * The band: its rows, whether they share one line, how many are left out,
61 * and the line for working somewhere else while one of them is close.
62 */
63export type Band = {
64 rows: Row[]
65 isSummary: boolean
66 more: number
67 nudge: string | null
68}
69
70export function tone(dueAt: number, now: number): Tone {
71 const left = dueAt - now
72
73 return left < 0
74 ? 'overdue'
75 : left < 3 * DAY
76 ? 'urgent'
77 : left < 7 * DAY
78 ? 'soon'
79 : 'calm'
80}
81
82/** `text` in at most `width` cells, its end traded for `…` when it is longer. */
83export function cut(text: string, width: number): string {
84 return text.length <= width
85 ? text
86 : width <= 1
87 ? text.slice(0, Math.max(0, width))
88 : `${text.slice(0, width - 1).trimEnd()}…`
89}
90
91/**
92 * The bar is a fuse burning from the left: this is how many of its cells are
93 * burnt, the share of the way from `start` to `due`. Only a deadline that is
94 * past has none left; `null` when there is no `start` to measure from.
95 */
96function burnt(one: Deadline, now: number, width: number): number | null {
97 if (now > one.dueAt) {
98 return width
99 }
100
101 if (one.startAt === null || one.startAt >= one.dueAt) {
102 return null
103 }
104
105 const share = (now - one.startAt) / (one.dueAt - one.startAt)
106 const cells = Math.round(share * width)
107
108 return share <= 0 ? 0 : Math.min(width - 1, Math.max(1, cells))
109}
110
111/** One line for a narrow column: `Atlas demo 7d · Reel clips 9d · +1 more`. */
112function summary(
113 all: Deadline[],
114 now: number,
115 columns: number,
116 { marks, cwd, maxRows }: Look,
117): Band {
118 const rows: Row[] = []
119 let used = 0
120
121 for (const one of all.slice(0, maxRows)) {
122 const left = timeLeft(one.dueAt, now)
123 const after = all.length - rows.length - 1
124 const tail = after > 0 ? ` · +${after} more`.length : 0
125 const gap = rows.length === 0 ? 0 : 3
126 const room = columns - used - gap - tail - left.length - 1
127 const isFirst = rows.length === 0
128
129 if (!isFirst && room < one.name.length) {
130 break
131 }
132
133 const name = cut(one.name, Math.max(1, room))
134 const kind = tone(one.dueAt, now)
135
136 used += gap + name.length + 1 + left.length
137 rows.push({
138 id: one.id,
139 tone: kind,
140 mark: MARK[marks][kind],
141 name,
142 spent: '',
143 ahead: '',
144 left,
145 next: '',
146 isHere: isInside(one.paths, cwd),
147 })
148 }
149
150 return { rows, isSummary: true, more: all.length - rows.length, nudge: null }
151}
152
153/**
154 * Lays the open deadlines out in `columns` cells, soonest first: full rows
155 * with a bar where there is room, one line where there is not.
156 */
157export function layout(
158 deadlines: Deadline[],
159 now: number,
160 columns: number,
161 look: Partial<Look> = {},
162): Band {
163 const { marks, cwd, maxRows, nudgeMs } = { ...LOOK, ...look }
164 const all = open(deadlines)
165
166 if (all.length === 0) {
167 return { rows: [], isSummary: false, more: 0, nudge: null }
168 }
169
170 if (columns < NARROW) {
171 return summary(all, now, columns, { marks, cwd, maxRows, nudgeMs })
172 }
173
174 const shown = all.slice(0, maxRows)
175 const barWidth = columns >= 100 ? 24 : columns >= 70 ? 16 : 10
176 const lefts = shown.map(one => timeLeft(one.dueAt, now))
177 const leftWidth = Math.max(...lefts.map(left => left.length))
178 const markWidth = MARK[marks].calm.length
179 // Mark and name a cell apart, then bar, time and next step two cells apart.
180 const fixed = markWidth + 1 + 2 + barWidth + 2 + leftWidth
181 const nameWidth = Math.max(
182 1,
183 Math.min(
184 columns >= 70 ? 24 : 14,
185 columns - fixed,
186 Math.max(...shown.map(one => one.name.length)),
187 ),
188 )
189 const room = columns - fixed - nameWidth - 2
190
191 const rows = shown.map((one, index): Row => {
192 const kind = tone(one.dueAt, now)
193 const cells = burnt(one, now, barWidth)
194
195 return {
196 id: one.id,
197 tone: kind,
198 mark: MARK[marks][kind],
199 name: cut(one.name, nameWidth).padEnd(nameWidth),
200 spent: cells === null ? '╌'.repeat(barWidth) : '─'.repeat(cells),
201 ahead: cells === null ? '' : '━'.repeat(barWidth - cells),
202 left: (lefts[index] ?? '').padStart(leftWidth),
203 next: one.next === null || room < 12 ? '' : cut(one.next, room),
204 isHere: isInside(one.paths, cwd),
205 }
206 })
207 const line = nudge(deadlines, now, cwd, nudgeMs)
208
209 return {
210 rows,
211 isSummary: false,
212 more: all.length - rows.length,
213 nudge: line === null ? null : cut(line, columns),
214 }
215}
216hooks/command.ts 416 lines1import type { Deadline } from '../types'
2
3import { open, parse, parseDate, slug, timeLeft } from './deadlines'
4import { isInside } from './smart'
5
6/**
7 * What one `/due` run comes to: the line to print, the whole new text of
8 * due.json when the run changed it, and the band's visibility when it set it.
9 */
10export type Outcome = {
11 text: string
12 file: string | null
13 isHidden: boolean | null
14}
15
16const DAYS = [
17 'sunday',
18 'monday',
19 'tuesday',
20 'wednesday',
21 'thursday',
22 'friday',
23 'saturday',
24]
25
26const MONTHS = [
27 'january',
28 'february',
29 'march',
30 'april',
31 'may',
32 'june',
33 'july',
34 'august',
35 'september',
36 'october',
37 'november',
38 'december',
39]
40
41const DATES = '2026-10-21, 21 oct, +9d or fri'
42const USAGE =
43 'Expected: /due add | next | done | here | rm | list | hide | show'
44
45const say = (text: string): Outcome => ({ text, file: null, isHidden: null })
46
47const two = (value: number) => String(value).padStart(2, '0')
48
49/** `fri` or `friday` in a list of full names; three letters at least. */
50const named = (names: string[], token: string) =>
51 token.length < 3 ? -1 : names.findIndex(name => name.startsWith(token))
52
53/** The day a date starts at `tokens[at]`, and how many tokens spell it. */
54function readDay(
55 tokens: string[],
56 at: number,
57 now: number,
58): { day: Date; used: number } | null {
59 const first = (tokens[at] ?? '').toLowerCase()
60 const second = (tokens[at + 1] ?? '').toLowerCase()
61 const today = new Date(now)
62 const ahead = (days: number) =>
63 new Date(today.getFullYear(), today.getMonth(), today.getDate() + days)
64
65 if (parseDate(first, true) !== null && first.length === 10) {
66 const [year, month, day] = first.split('-').map(Number)
67
68 return { day: new Date(year ?? 0, (month ?? 1) - 1, day ?? 1), used: 1 }
69 }
70
71 const offset = /^\+(\d{1,4})d$/.exec(first)
72
73 if (offset !== null) {
74 return { day: ahead(Number(offset[1])), used: 1 }
75 }
76
77 if (first === 'today' || first === 'tomorrow') {
78 return { day: ahead(first === 'today' ? 0 : 1), used: 1 }
79 }
80
81 const weekday = named(DAYS, first)
82
83 // The coming one: `fri` typed on a Friday is a week away, `today` is today.
84 if (weekday !== -1) {
85 return { day: ahead(((weekday - today.getDay() + 6) % 7) + 1), used: 1 }
86 }
87
88 const isDayFirst = /^\d{1,2}$/.test(first)
89 const month = named(MONTHS, isDayFirst ? second : first)
90 const dayText = isDayFirst ? first : second
91
92 if (month === -1 || !/^\d{1,2}$/.test(dayText)) {
93 return null
94 }
95
96 const inYear = (year: number) => {
97 const day = new Date(year, month, Number(dayText))
98
99 return day.getMonth() === month ? day : null
100 }
101 const thisYear = inYear(today.getFullYear())
102 const isPast = thisYear !== null && thisYear.getTime() < ahead(0).getTime()
103 const day = isPast ? inYear(today.getFullYear() + 1) : thisYear
104
105 return day === null ? null : { day, used: 2 }
106}
107
108/**
109 * Reads a date typed at `tokens[at]`, with a time after it when there is one
110 * (`21 oct 18:00`), as due.json spells it.
111 */
112export function readWhen(
113 tokens: string[],
114 at: number,
115 now: number,
116): { due: string; used: number } | null {
117 const found = readDay(tokens, at, now)
118
119 if (found === null) {
120 return null
121 }
122
123 const { day, used } = found
124 const date = `${day.getFullYear()}-${two(day.getMonth() + 1)}-${two(day.getDate())}`
125 const time = /^(\d{1,2}):(\d{2})$/.exec(tokens[at + used] ?? '')
126 const due = time === null ? date : `${date} ${two(Number(time[1]))}:${time[2]}`
127
128 if (parseDate(due, true) !== null) {
129 return { due, used: time === null ? used : used + 1 }
130 }
131
132 return time !== null && parseDate(date, true) !== null ? { due: date, used } : null
133}
134
135/** `Wed 21 Oct`, with the year when it is not this one. */
136function dayLabel(at: number, now: number): string {
137 const day = new Date(at)
138 const weekday = (DAYS[day.getDay()] ?? '').slice(0, 3)
139 const month = (MONTHS[day.getMonth()] ?? '').slice(0, 3)
140 const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1)
141 const year =
142 day.getFullYear() === new Date(now).getFullYear()
143 ? ''
144 : ` ${day.getFullYear()}`
145
146 return `${cap(weekday)} ${day.getDate()} ${cap(month)}${year}`
147}
148
149/** The entry a typed id means: the id itself, or the one id it begins. */
150function find(deadlines: Deadline[], typed: string): number | string {
151 const key = typed.toLowerCase()
152 const exact = deadlines.findIndex(one => one.id.toLowerCase() === key)
153
154 if (exact !== -1) {
155 return exact
156 }
157
158 const close = deadlines
159 .map((one, index) => ({ id: one.id, index }))
160 .filter(one => one.id.toLowerCase().startsWith(key))
161 const only = close.length === 1 ? close[0] : undefined
162
163 if (only !== undefined) {
164 return only.index
165 }
166
167 const ids = (close.length > 1 ? close : deadlines).map(one => one.id)
168
169 return close.length > 1
170 ? `"${typed}" fits ${ids.join(', ')}. Type more of the id.`
171 : ids.length === 0
172 ? `No deadline "${typed}"; there are none yet.`
173 : `No deadline "${typed}". Ids: ${ids.join(', ')}`
174}
175
176function list(deadlines: Deadline[], now: number): string {
177 if (deadlines.length === 0) {
178 return 'No deadlines. Add one: /due add <name> <date> [next step]'
179 }
180
181 const rows = [...open(deadlines), ...deadlines.filter(one => one.isDone)]
182 const idWidth = Math.max(...rows.map(one => one.id.length))
183 const nameWidth = Math.max(...rows.map(one => one.name.length))
184
185 return rows
186 .map(one => {
187 const left = one.isDone ? 'done' : timeLeft(one.dueAt, now)
188 const when = `${dayLabel(one.dueAt, now)}, ${left}`
189 const next = one.next === null || one.isDone ? '' : ` ${one.next}`
190
191 return `${one.id.padEnd(idWidth)} ${one.name.padEnd(nameWidth)} ${when}${next}`
192 })
193 .join('\n')
194}
195
196/** due.json as its deadlines and its entries, with the way to write it back. */
197type Book = {
198 deadlines: Deadline[]
199 entries: Record<string, unknown>[]
200 save: (line: string, next: Record<string, unknown>[]) => Outcome
201}
202
203/** Opens the text of due.json (`null`: no file yet), or says why it cannot. */
204function read(text: string | null): Book | { error: string } {
205 const { deadlines, error } = parse(text ?? '{"deadlines":[]}')
206
207 if (error !== null) {
208 return {
209 error: `due.json: ${error}. Fix the file, then run the command again.`,
210 }
211 }
212
213 // The entries as the file holds them, so a write keeps every field it has,
214 // also the ones this mod does not read.
215 const data = (text === null ? {} : JSON.parse(text)) as Record<string, unknown>
216 const entries = (data.deadlines ?? []) as Record<string, unknown>[]
217
218 return {
219 deadlines,
220 entries,
221 save: (line, next) => ({
222 text: line,
223 file: `${JSON.stringify({ ...data, deadlines: next }, null, 2)}
224`,
225 isHidden: null,
226 }),
227 }
228}
229
230/** Adds one deadline under an id no other has, starting today. */
231function append(
232 book: Book,
233 name: string,
234 due: string,
235 step: string,
236 now: number,
237): Outcome {
238 const base = slug(name) || 'deadline'
239 const taken = new Set(book.deadlines.map(one => one.id))
240 let id = base
241
242 for (let count = 2; taken.has(id); count += 1) {
243 id = `${base}-${count}`
244 }
245
246 const entry = {
247 id,
248 name,
249 due,
250 start: readWhen(['today'], 0, now)?.due,
251 ...(step === '' ? {} : { next: step }),
252 }
253 const dueAt = parseDate(due, true) ?? now
254
255 return book.save(
256 `Added ${name} (${id}), due ${dayLabel(dueAt, now)}, ${timeLeft(dueAt, now)}.`,
257 [...book.entries, entry],
258 )
259}
260
261/**
262 * Adds a deadline from its parts, as a tool hands them over: no guessing
263 * where the name ends. `when` is anything `/due add` reads as a date.
264 */
265export function add(
266 name: string,
267 when: string,
268 step: string,
269 text: string | null,
270 now: number,
271): Outcome {
272 const book = read(text)
273 const tokens = when.trim().split(/\s+/).filter(Boolean)
274 const found = readWhen(tokens, 0, now)
275 const label = name.trim().replace(/\s+/g, ' ')
276
277 if ('error' in book) {
278 return say(book.error)
279 }
280
281 if (label === '') {
282 return say('A deadline needs a name.')
283 }
284
285 if (found === null || found.used !== tokens.length) {
286 return say(`"${when}" is not a date. Expected one like ${DATES}`)
287 }
288
289 return append(book, label, found.due, step.trim().replace(/\s+/g, ' '), now)
290}
291
292/**
293 * Runs `/due <args>` against the text of due.json (`null` when there is no
294 * file yet) and answers what to print and what to write. It touches nothing
295 * itself, so the same call serves the command and its tests.
296 */
297export function run(
298 args: string,
299 text: string | null,
300 now: number,
301 cwd = '',
302): Outcome {
303 const tokens = args.trim().split(/\s+/).filter(Boolean)
304 const verb = (tokens[0] ?? 'list').toLowerCase()
305
306 if (verb === 'hide' || verb === 'show') {
307 return {
308 text:
309 verb === 'hide'
310 ? 'Band hidden for this session. /due show brings it back.'
311 : 'Band shown.',
312 file: null,
313 isHidden: verb === 'hide',
314 }
315 }
316
317 const book = read(text)
318
319 if ('error' in book) {
320 return say(book.error)
321 }
322
323 const { deadlines, entries, save } = book
324
325 if (verb === 'list') {
326 return say(list(deadlines, now))
327 }
328
329 if (verb === 'add') {
330 for (let at = 2; at < tokens.length; at += 1) {
331 const when = readWhen(tokens, at, now)
332
333 if (when !== null) {
334 const name = tokens.slice(1, at).join(' ')
335 const step = tokens.slice(at + when.used).join(' ')
336
337 return append(book, name, when.due, step, now)
338 }
339 }
340
341 return say(
342 `Expected: /due add <name> <date> [next step], the date like ${DATES}`,
343 )
344 }
345
346 if (verb !== 'next' && verb !== 'done' && verb !== 'rm' && verb !== 'here') {
347 return say(USAGE)
348 }
349
350 const typed = tokens[1]
351
352 if (typed === undefined) {
353 return say(
354 verb === 'next'
355 ? 'Expected: /due next <id> <next step>'
356 : `Expected: /due ${verb} <id>`,
357 )
358 }
359
360 const index = find(deadlines, typed)
361
362 if (typeof index === 'string') {
363 return say(index)
364 }
365
366 const name = deadlines[index]?.name ?? typed
367
368 // Ties the session's folder to the deadline: what the band reads to know
369 // whether the person is working on it or somewhere else.
370 if (verb === 'here') {
371 const paths = deadlines[index]?.paths ?? []
372
373 if (cwd === '') {
374 return say('This session has no folder to tie the deadline to.')
375 }
376
377 if (isInside(paths, cwd)) {
378 return say(`${name} already has this folder.`)
379 }
380
381 const folder = cwd.replace(/\\/g, '/')
382
383 return save(
384 `${name} is tied to ${folder}.`,
385 entries.map((one, at) =>
386 at === index ? { ...one, paths: [...paths, folder] } : one,
387 ),
388 )
389 }
390
391 if (verb === 'rm') {
392 return save(
393 `Removed ${name}.`,
394 entries.filter((_, at) => at !== index),
395 )
396 }
397
398 if (verb === 'done') {
399 return save(
400 `${name} is done.`,
401 entries.map((one, at) => (at === index ? { ...one, done: true } : one)),
402 )
403 }
404
405 const step = tokens.slice(2).join(' ')
406
407 if (step === '') {
408 return say('Expected: /due next <id> <next step>')
409 }
410
411 return save(
412 `${name}, next: ${step}`,
413 entries.map((one, at) => (at === index ? { ...one, next: step } : one)),
414 )
415}
416hooks/deadlines.ts 151 lines1import type { Deadline, Loaded } from '../types'
2
3const MINUTE = 60_000
4const HOUR = 60 * MINUTE
5const DAY = 24 * HOUR
6
7export const EMPTY: Loaded = { deadlines: [], error: null }
8
9const broken = (error: string): Loaded => ({ deadlines: [], error })
10
11// Accents are dropped rather than cut out, so `Ödev çizimi` is `odev-cizimi`:
12// an id stays typeable on any keyboard.
13export const slug = (name: string) =>
14 name
15 .toLowerCase()
16 .normalize('NFD')
17 .replace(/[̀-ͯ]/g, '')
18 .replace(/ı/g, 'i')
19 .replace(/[^a-z0-9]+/g, '-')
20 .replace(/^-|-$/g, '')
21
22/**
23 * Reads `2026-10-21` or `2026-10-21 18:00` as local time. A date alone is the
24 * end of that day for a due date and its first minute for a start.
25 */
26export function parseDate(text: string, isEndOfDay: boolean): number | null {
27 const found = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?$/.exec(
28 text.trim(),
29 )
30
31 if (found === null) {
32 return null
33 }
34
35 const year = Number(found[1])
36 const month = Number(found[2])
37 const day = Number(found[3])
38 const hasTime = found[4] !== undefined
39 const hours = hasTime ? Number(found[4]) : isEndOfDay ? 23 : 0
40 const minutes = hasTime ? Number(found[5]) : isEndOfDay ? 59 : 0
41 const at = new Date(year, month - 1, day, hours, minutes)
42 const isReal = at.getMonth() === month - 1 && at.getDate() === day
43
44 return isReal && hours < 24 && minutes < 60 ? at.getTime() : null
45}
46
47/** One entry of the file, or the reason it cannot be read. */
48function readEntry(raw: unknown): Deadline | string {
49 if (typeof raw !== 'object' || raw === null) {
50 return 'not an object'
51 }
52
53 const { id, name, due, start, next, paths, done } = raw as Record<
54 string,
55 unknown
56 >
57
58 if (typeof name !== 'string' || name.trim() === '') {
59 return 'needs a "name"'
60 }
61
62 const dueAt = typeof due === 'string' ? parseDate(due, true) : null
63
64 if (typeof due !== 'string' || dueAt === null) {
65 return `"due" of ${name} must look like 2026-10-21 or 2026-10-21 18:00`
66 }
67
68 const startAt = typeof start === 'string' ? parseDate(start, false) : null
69
70 if (start !== undefined && start !== null && startAt === null) {
71 return `"start" of ${name} must look like 2026-10-07`
72 }
73
74 return {
75 id: typeof id === 'string' && id !== '' ? id : slug(name),
76 name: name.trim(),
77 due,
78 dueAt,
79 start: typeof start === 'string' ? start : null,
80 startAt,
81 next: typeof next === 'string' && next.trim() !== '' ? next.trim() : null,
82 paths: Array.isArray(paths)
83 ? paths.filter((path): path is string => typeof path === 'string')
84 : [],
85 isDone: done === true,
86 }
87}
88
89/** The text of due.json as deadlines, or as the one reason it is broken. */
90export function parse(text: string): Loaded {
91 let data: unknown
92
93 try {
94 data = JSON.parse(text)
95 } catch {
96 return broken('not valid JSON')
97 }
98
99 const list = (data as { deadlines?: unknown } | null)?.deadlines
100
101 if (!Array.isArray(list)) {
102 return broken('expected { "deadlines": [ ... ] }')
103 }
104
105 const deadlines: Deadline[] = []
106
107 for (const [index, raw] of list.entries()) {
108 const entry = readEntry(raw)
109
110 if (typeof entry === 'string') {
111 return broken(`entry ${index + 1}: ${entry}`)
112 }
113
114 deadlines.push(entry)
115 }
116
117 return { deadlines, error: null }
118}
119
120/**
121 * Where due.json lives: the path the person set (`~` is their home folder),
122 * or `.claude` in the home folder when they set none.
123 */
124export function filePath(home: string | undefined, custom = ''): string {
125 const root = (home ?? '.').replace(/[\\/]$/, '')
126
127 return custom === ''
128 ? `${root}/.claude/due.json`
129 : custom.replace(/^~(?=$|[\\/])/, root)
130}
131
132/** What is still open, soonest first. */
133export function open(deadlines: Deadline[]): Deadline[] {
134 return deadlines
135 .filter(one => !one.isDone)
136 .sort((a, b) => a.dueAt - b.dueAt)
137}
138
139/** `9d`, `31h` inside the last two days, `40m` inside the last hour. */
140export function timeLeft(dueAt: number, now: number): string {
141 const size = Math.abs(dueAt - now)
142 const span =
143 size < HOUR
144 ? `${Math.max(1, Math.floor(size / MINUTE))}m`
145 : size < 2 * DAY
146 ? `${Math.floor(size / HOUR)}h`
147 : `${Math.floor(size / DAY)}d`
148
149 return dueAt < now ? `${span} overdue` : span
150}
151hooks/settings.ts 47 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Marks } from './band'
4
5/** The mod's settings as the code reads them: plugin.json's `userConfig`. */
6export type Settings = {
7 /** Where due.json is; `''` for `.claude/due.json` in the home folder. */
8 file: string
9 /** How near a deadline is before working elsewhere is said; 0 for never. */
10 nudgeDays: number
11 /** How many deadlines the band lists before `+N more`. */
12 maxRows: number
13 /** Whether Claude is told the deadlines and given the tools. */
14 tellClaude: boolean
15 /** Whether a deadline crossing a threshold raises a toast. */
16 toasts: boolean
17 marks: Marks
18}
19
20export const DEFAULTS: Settings = {
21 file: '',
22 nudgeDays: 3,
23 maxRows: 3,
24 tellClaude: true,
25 toasts: true,
26 marks: 'emoji',
27}
28
29const within = (value: unknown, low: number, high: number, or: number) =>
30 typeof value === 'number' && Number.isFinite(value)
31 ? Math.min(high, Math.max(low, Math.round(value)))
32 : or
33
34/** The options the engine hands `register`, each held to what the mod takes. */
35export function settingsOf(options: PluginOptions): Settings {
36 const { file, nudgeDays, maxRows, tellClaude, toasts, marks } = options
37
38 return {
39 file: typeof file === 'string' ? file.trim() : DEFAULTS.file,
40 nudgeDays: within(nudgeDays, 0, 30, DEFAULTS.nudgeDays),
41 maxRows: within(maxRows, 1, 10, DEFAULTS.maxRows),
42 tellClaude: typeof tellClaude === 'boolean' ? tellClaude : DEFAULTS.tellClaude,
43 toasts: typeof toasts === 'boolean' ? toasts : DEFAULTS.toasts,
44 marks: marks === 'plain' ? 'plain' : DEFAULTS.marks,
45 }
46}
47hooks/smart.ts 171 lines1import type { Deadline } from '../types'
2
3import { open, timeLeft } from './deadlines'
4
5const DAY = 24 * 60 * 60_000
6
7/** How near a deadline must be before working elsewhere is worth a word. */
8export const NUDGE_MS = 3 * DAY
9
10/** The thresholds a toast marks, in the order a deadline crosses them. */
11export type Stage = '3d' | '1d' | 'overdue'
12
13/** The last stage each deadline was toasted at, by id: what the store keeps. */
14export type Toasted = Record<string, Stage>
15
16const RANK: Record<Stage, number> = { '3d': 1, '1d': 2, overdue: 3 }
17
18const SAYS: Record<Stage, string> = {
19 '3d': 'is due in under 3 days',
20 '1d': 'is due in under a day',
21 overdue: 'is overdue',
22}
23
24/** A path as one spelling: forward slashes, no trailing one, lower case. */
25const plain = (path: string) =>
26 path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
27
28/** Whether `cwd` is one of the folders, or anywhere under one. */
29export function isInside(paths: string[], cwd: string): boolean {
30 const here = plain(cwd)
31
32 return (
33 here !== '' &&
34 paths.some(path => {
35 const root = plain(path)
36
37 return root !== '' && (here === root || here.startsWith(`${root}/`))
38 })
39 )
40}
41
42/** The last name of a path: `reel-clips` of `C:\work\reel-clips`. */
43export const folder = (cwd: string) => plain(cwd).split('/').pop() ?? ''
44
45/**
46 * The line for working in the wrong place: the soonest deadline inside the
47 * threshold whose folders do not hold this session. `null` when there is
48 * none, or when the session's own deadline is due sooner still.
49 */
50export function nudge(
51 deadlines: Deadline[],
52 now: number,
53 cwd: string,
54 withinMs = NUDGE_MS,
55): string | null {
56 if (cwd === '' || withinMs <= 0) {
57 return null
58 }
59
60 for (const one of open(deadlines)) {
61 if (one.dueAt - now >= withinMs) {
62 return null
63 }
64
65 if (isInside(one.paths, cwd)) {
66 return null
67 }
68
69 if (one.paths.length > 0) {
70 const left = timeLeft(one.dueAt, now)
71 const when = one.dueAt < now ? `is ${left}` : `is due in ${left}`
72
73 return `${one.name} ${when}, and you are in ${folder(cwd)}`
74 }
75 }
76
77 return null
78}
79
80function stage(dueAt: number, now: number): Stage | null {
81 const left = dueAt - now
82
83 return left < 0 ? 'overdue' : left < DAY ? '1d' : left < 3 * DAY ? '3d' : null
84}
85
86/**
87 * What to toast now, and what to remember having toasted. A deadline is
88 * named once for each threshold it crosses; one moved further away is
89 * forgotten, so it is named again on its way back.
90 */
91export function thresholds(
92 deadlines: Deadline[],
93 now: number,
94 seen: Toasted,
95): { toasts: string[]; seen: Toasted } {
96 const toasts: string[] = []
97 const next: Toasted = {}
98
99 for (const one of open(deadlines)) {
100 const at = stage(one.dueAt, now)
101 const before = seen[one.id]
102
103 if (at === null) {
104 continue
105 }
106
107 next[one.id] = at
108
109 if (before === undefined || RANK[at] > RANK[before]) {
110 toasts.push(`${one.name} ${SAYS[at]}`)
111 }
112 }
113
114 return { toasts, seen: next }
115}
116
117/** What the store holds, read back as stages by id; anything else is none. */
118export function readToasted(raw: unknown): Toasted {
119 const seen: Toasted = {}
120
121 if (typeof raw !== 'object' || raw === null) {
122 return seen
123 }
124
125 for (const [id, value] of Object.entries(raw)) {
126 if (value === '3d' || value === '1d' || value === 'overdue') {
127 seen[id] = value
128 }
129 }
130
131 return seen
132}
133
134/**
135 * The section of the system prompt that tells Claude what is due. Days, not
136 * hours, so the text changes once a day and the prompt cache holds.
137 */
138export function brief(
139 deadlines: Deadline[],
140 now: number,
141 cwd: string,
142): string | null {
143 const rows = open(deadlines)
144
145 if (rows.length === 0) {
146 return null
147 }
148
149 const lines = rows.map(one => {
150 const days = Math.floor(Math.abs(one.dueAt - now) / DAY)
151 const left =
152 one.dueAt < now
153 ? `${days}d overdue`
154 : days === 0
155 ? 'due today'
156 : `${days}d left`
157 const next = one.next === null ? '' : `; next step: ${one.next}`
158 const here = isInside(one.paths, cwd) ? '; this session is in its folder' : ''
159
160 return `- ${one.id}: ${one.name}, due ${one.due} (${left})${next}${here}`
161 })
162
163 return [
164 '# Deadlines',
165 '',
166 'The person tracks these deadlines with the due-band mod, which shows them above the prompt. Bring one up only when it bears on what they are doing. When they say a step is finished, a deadline is met, or a new one exists, update the list with the tools `mcp__due-band__set_next_step`, `mcp__due-band__mark_done` and `mcp__due-band__add_deadline` instead of editing the file.',
167 '',
168 ...lines,
169 ].join('\n')
170}
171types/index.d.ts 33 lines1/**
2 * One entry of due.json, as written, with its dates also read into
3 * milliseconds since the epoch.
4 */
5export type Deadline = {
6 id: string
7 name: string
8 due: string
9 dueAt: number
10 start: string | null
11 startAt: number | null
12 next: string | null
13 paths: string[]
14 isDone: boolean
15}
16
17/** What the last read of due.json came to. */
18export type Loaded = { deadlines: Deadline[]; error: string | null }
19
20declare module 'claude-code' {
21 interface PluginState {
22 'due-band': {
23 loaded: Loaded
24 now: number
25 isHidden: boolean
26 /** The session's working folder at the last refresh; `''` before one. */
27 cwd: string
28 /** The wrong-project line this session already toasted; `''` for none. */
29 nudged: string
30 }
31 }
32}
33