Are you moving or hiding in the dark? Watches the session for shipped work (commits, pushes, deploys, passing tests) against edits to tooling, config and docs…

A Claude Code mod that tells you whether you are moving or hiding in the dark.
It watches a session through Claude Code's mod events and counts two things: what shipped (commits, pushes, deploys, passing test runs) and what you only tidied (edits to tooling, config, docs and plans). It is silent while you are moving. When you have gone a long stretch with nothing shipped, or most of your recent edits went to config and docs, or you keep rewriting one file, one plain line appears above the prompt. Once.
It uses no model tokens, makes no network calls, runs no processes and reads no files. It counts tool calls and keeps the totals in the mod's own store on your machine.
src/app.ts has been edited 4 times with nothing shipped. 12 of your last 20 edits were config and docs.
Needs Claude Code 2.1.287 or later (mods are on by default from that version).
claude plugin marketplace add azkhh/drift
claude plugin install drift@drift
Or from inside Claude Code: /plugin, add the marketplace azkhh/drift, install drift. Restart the session, then run /drift.
To try it from a clone without installing:
claude --plugin-dir ./drift
Nothing, until one of these is crossed. Each one speaks once per crossing, then stays quiet until the thing it measures drops back (a ship resets the first and third).
| Threshold | Line |
|---|---|
| 45 active minutes since anything shipped | 52 min since anything shipped. |
| 60% or more of your last 20 edits (at least 10 edits) went to the dark | 14 of your last 20 edits were config and docs. |
| The same file edited 4 times with nothing shipped between | src/app.ts has been edited 4 times with nothing shipped. |
The line stays above the prompt until you send your next prompt, or until something ships. A ship is a commit, push, deploy or test run that exited 0.
Active time counts only the gaps between events that are 10 minutes or shorter. Go for lunch and it is idle, not drift.
/drift| Command | What it does |
|---|---|
/drift | Today's receipt and the last 7 days: active time, shipped count, time in the dark, churn files, failed tries, the goal and how long it has stood. No model turn. |
/drift goal <text> | Sets one goal for this project (keyed by the repository root). The receipt says whether any edited path or commit message since then shares a meaningful word with it. |
/drift goal | Shows the goal. /drift goal clear removes it. |
/drift off, /drift on | Stop or resume counting in this project. |
/drift reset | Clears this project's counters and history. Keeps the goal and the on/off switch. |
An edit is "in the dark" when its path matches one of these globs (case does not matter, a pattern with no slash matches a file name at any depth). Everything else is the work.
CLAUDE.md AGENTS.md **/.claude/**
*.config.* package.json tsconfig* **/.github/**
.* **/.*/** (dotfiles and dot folders)
docs/**/*.md **/docs/**/*.md plan.md notes.md todo.md roadmap.md readme.md
*.lock *.lockb package-lock.json pnpm-lock.yaml go.sum
The list lives in one place, hooks/paths.ts. To replace it, set the plugin option Dark patterns (/plugin configure drift@drift, or the row for this plugin in /config) to a comma separated list of globs. The install prints a note that one option is not set yet; it is optional. A non empty value replaces the built in list, it does not add to it.
The mod is built against the Claude Code 2.1.287 declarations. Here is what each part rests on.
Edits. Edit, Write, MultiEdit and NotebookEdit tool calls carry the path. An edit counts only if the call landed: a call that failed (isError) or was refused (deny) counts for nothing.
Ships. Bash tool calls carry the command text, which the mod reads for git commit, git push, vercel deploy, wrangler deploy, npm publish, fly deploy, netlify deploy, and npm test, pnpm test, yarn test, bun test, pytest, go test, cargo test, vitest and jest. Dry runs are ignored. Compound commands are split at &&, ||, ; and |, never inside quotes.
Exit status. The Bash tool result in 2.1.287 has no numeric exit code. Its fields are stdout, stderr, interrupted, backgroundTaskId and a few more. What the hook does see is isError, which the engine sets when the tool reported an error, and a non zero exit is one. In a live run, T=1 npm test (exit 1) arrived as isError: true and was counted as a failed try, and the same command with exit 0 counted as a passing test run. So the mod knows zero from not zero, and nothing finer. A command sent to the background has not finished and is not counted as shipped.
What that does not catch.
git commit -m x && git push exiting 0 means both ran. a; b and a || b can exit 0 with one part failed, and npm test | tail exits with tail's status, so a failing test run piped through another command counts as a pass.Bash (sed -i, echo >> file, a formatter) are not edits to the mod. Only the edit tools are.npm run release), a deploy from CI, a commit you make in another terminal.Approximations. "Time in the dark" is the active time whose closing event was an edit to a dark file. The dark share needs at least 10 edits in the window before it can speak, so three config edits in a row are not a verdict. A break of more than 8 hours starts a fresh run, so yesterday's edits do not count against today. The goal match is one shared word (lowercased, plurals folded, common words and file extensions ignored); it cannot read intent, it can only tell you that nothing you touched has mentioned the goal.
claude plugin validate lists every call the module makes: the clock, the session's root and repository, the mod's own state and store, and the command registration. It makes no $.http, $.process, $.fs or $.model call. Per project it stores the goal and, for the last 30 days, a few counts per day and the names of the files that churned. File paths are stored relative to the project root. The store is a JSON file in Claude Code's plugin store folder. Nothing is sent anywhere.
claude plugin test . # 65 tests, run by the engine's own host
claude plugin validate .claude-plugin/plugin.json
claude --plugin-dir . # try it live; the folder is watched and hot reloads
Claude Code writes its type declarations to .claude-plugin/types/ every time it loads a plugin folder; that folder is git ignored. npx -p typescript tsc -p tsconfig.json type checks the hooks and tests against it.
The code is split so each piece can be tested on its own:
| File | What it holds |
|---|---|
hooks/paths.ts | The dark list, the globs, the option |
hooks/ships.ts | Finding commits, pushes, deploys and test runs in a command line |
hooks/goal.ts | The goal and its shared word check |
hooks/tracker.ts | Active time, the edit window, churn, ships, day totals, the three thresholds |
hooks/receipt.ts | The text /drift prints |
hooks/mover-line.ts | The one line that ends /drift |
hooks/register.ts | The hooks: tool.call, prompt.submit, turn.complete, session.start, session.end, the AbovePrompt band, command.run |
Every hook does a constant amount of work and no disk scan. In a scripted run the engine's debug log showed the tool.call hook settling in 6 to 15 ms for an edit, with the edit itself included in that time.
MIT, see LICENSE.
drift only sees your edits. mover remembers what you said you'd do and checks it against what you did: moveros.dev
hooks/register.ts 326 lines1import type { EngineInterface, Register, RenderElement } from 'claude-code'
2
3import { newGoal } from './goal'
4import { isDark, parsePatterns, relativeTo } from './paths'
5import { receiptOf } from './receipt'
6import { shipsIn } from './ships'
7import {
8 clearBand,
9 emptyLive,
10 recordActivity,
11 recordEdit,
12 recordShip,
13 reviveProject,
14 startSession,
15} from './tracker'
16import type { Project } from './tracker'
17
18// A literal reference, as `$.state` requires: the host holds the band's line
19// so a redraw reads it and a write redraws exactly the readers.
20const BAND = { plugin: 'drift', key: 'band' } as const
21
22const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
23
24// A prompt the person typed, as opposed to a notification or a plugin's own.
25const PERSON_ORIGINS = new Set(['composer', 'bridge', 'sdk', 'unclassified'])
26
27const USAGE =
28 'drift takes: /drift (today and the last 7 days), /drift goal <text>, ' +
29 '/drift goal clear, /drift off, /drift on, /drift reset.'
30
31type Context = {
32 /** Where the session stood when this was resolved, to notice a move. */
33 sessionRoot: string
34 key: string
35 project: Project
36 /** The line last written to the band, so an unchanged one is not rewritten. */
37 shown: string | null
38}
39
40type Engine = EngineInterface
41
42/**
43 * The project the session is in, from the store, keyed by the repository
44 * root. Resolved again only when the session's root moves.
45 */
46async function projectOf(
47 $: Engine,
48 held: Context | undefined,
49): Promise<Context> {
50 const sessionRoot = await $.session.root()
51
52 if (held && held.sessionRoot === sessionRoot) {
53 return held
54 }
55
56 const repo = await $.session.repo().catch(() => null)
57 const key = `p:${repo?.root ?? sessionRoot}`
58
59 return {
60 sessionRoot,
61 key,
62 project: reviveProject(await $.store.get(key)),
63 shown: null,
64 }
65}
66
67function pathOf(input: Record<string, unknown>): string | undefined {
68 const path = input.file_path ?? input.notebook_path
69
70 return typeof path === 'string' ? path : undefined
71}
72
73/**
74 * Whether a Bash result is a command that finished and exited 0. The engine
75 * sets `isError` when the tool reported an error, which a non zero exit is;
76 * a command sent to the background has not finished, so has not shipped.
77 */
78function isFinishedOk(result: { isError?: true; result?: unknown }): boolean {
79 if (result.isError === true) {
80 return false
81 }
82
83 const done = result.result
84
85 if (typeof done === 'object' && done !== null) {
86 const record = done as Record<string, unknown>
87
88 return !record.backgroundTaskId && record.interrupted !== true
89 }
90
91 return true
92}
93
94type Holder = { current?: Context }
95
96async function context($: Engine, holder: Holder): Promise<Context> {
97 holder.current = await projectOf($, holder.current)
98
99 return holder.current
100}
101
102async function persist($: Engine, c: Context): Promise<void> {
103 await $.store.set(c.key, c.project)
104}
105
106async function showBand($: Engine, c: Context): Promise<void> {
107 const line = c.project.enabled ? c.project.live.band : null
108
109 if (line !== c.shown) {
110 c.shown = line
111 await $.state.set(BAND, line)
112 }
113}
114
115export const register: Register = (on, options) => {
116 const patterns = parsePatterns(options.darkPatterns)
117 // Per load of the module, never module level: a fresh load starts clean.
118 const holder: Holder = {}
119
120 on('session.start', async ($, e, next) => {
121 try {
122 await $.command.register({
123 name: 'drift',
124 description:
125 'Are you moving or hiding in the dark? Today, the last 7 days, your goal.',
126 argumentHint: '[goal <text> | off | on | reset]',
127 })
128
129 const c = await context($, holder)
130
131 startSession(c.project, await $.clock.now())
132 await showBand($, c)
133 } catch {
134 // A mod must never stop a session from starting.
135 }
136
137 return next(e)
138 })
139
140 on('session.end', async ($, e, next) => {
141 try {
142 const c = await context($, holder)
143
144 await persist($, c)
145 } catch {
146 // Nothing to do at the end of a session but let it end.
147 }
148
149 return next(e)
150 })
151
152 on('prompt.submit', async ($, e, next) => {
153 try {
154 const c = await context($, holder)
155
156 if (c.project.enabled) {
157 recordActivity(c.project, await $.clock.now())
158
159 if (PERSON_ORIGINS.has(e.origin.kind)) {
160 clearBand(c.project)
161 }
162
163 await showBand($, c)
164 }
165 } catch {
166 // The prompt goes through whatever happened here.
167 }
168
169 return next(e)
170 })
171
172 on('turn.complete', async ($, e, next) => {
173 try {
174 const c = await context($, holder)
175
176 if (c.project.enabled) {
177 recordActivity(c.project, await $.clock.now())
178
179 await showBand($, c)
180
181 if (e.agentId === undefined) await persist($, c)
182 }
183 } catch {
184 // The turn is over either way.
185 }
186
187 return next(e)
188 })
189
190 on('tool.call', async ($, e, next) => {
191 const result = await next(e)
192
193 try {
194 const c = await context($, holder)
195
196 if (!c.project.enabled) {
197 return result
198 }
199
200 const now = await $.clock.now()
201 const tool = String(e.tool)
202 const input = e as unknown as Record<string, unknown>
203 const hasLanded = result.deny === undefined && result.isError !== true
204 let hasShipAttempt = false
205
206 if (EDIT_TOOLS.has(tool) && hasLanded) {
207 const path = pathOf(input)
208
209 if (path !== undefined) {
210 const relative = relativeTo(c.sessionRoot, path)
211
212 recordEdit(c.project, now, relative, isDark(relative, patterns))
213 } else {
214 recordActivity(c.project, now)
215 }
216 } else if (tool === 'Bash' && typeof input.command === 'string') {
217 const hits = shipsIn(input.command)
218
219 if (hits.length === 0 || result.deny !== undefined) {
220 recordActivity(c.project, now)
221 } else {
222 const isOk = isFinishedOk(result)
223
224 for (const hit of hits) {
225 recordShip(c.project, now, hit, isOk)
226 }
227
228 hasShipAttempt = true
229 }
230 } else {
231 recordActivity(c.project, now)
232 }
233
234 await showBand($, c)
235
236 // What shipped is on disk before the next thing happens.
237 if (hasShipAttempt) await persist($, c)
238 } catch {
239 // Counting must never break a tool call.
240 }
241
242 return result
243 })
244
245 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246 if (e.props.hasSurvey) {
247 return next(e)
248 }
249
250 const { value } = await $.state.get(BAND)
251
252 if (!value) {
253 return next(e)
254 }
255
256 const { Box, Text } = $.ui.resolve(e)
257
258 return h(Box, null, h(Text, null, value)) as RenderElement
259 })
260
261 on('command.run', { command: 'drift' }, async ($, e) => {
262 const c = await context($, holder)
263 const now = await $.clock.now()
264 const [word = '', ...rest] = e.args.trim().split(/\s+/)
265 const text = rest.join(' ').trim()
266
267 switch (word.toLowerCase()) {
268 case '':
269 return { text: receiptOf(c.project, now) }
270
271 case 'off':
272 c.project.enabled = false
273 c.project.live.band = null
274 await showBand($, c)
275 await persist($, c)
276
277 return {
278 text: 'drift is off for this project. /drift on turns it back on.',
279 }
280
281 case 'on':
282 c.project.enabled = true
283 await persist($, c)
284
285 return { text: 'drift is on for this project.' }
286
287 case 'reset':
288 c.project.days = {}
289 c.project.live = emptyLive()
290 await showBand($, c)
291 await persist($, c)
292
293 return {
294 text: 'drift reset for this project: counters and history cleared, goal and on/off kept.',
295 }
296
297 case 'goal': {
298 if (text === '') {
299 const { goal } = c.project
300
301 return {
302 text: goal
303 ? `goal: ${goal.text}`
304 : 'no goal set. /drift goal <text> sets one.',
305 }
306 }
307
308 if (text.toLowerCase() === 'clear') {
309 c.project.goal = undefined
310 await persist($, c)
311
312 return { text: 'goal cleared.' }
313 }
314
315 c.project.goal = newGoal(text, now)
316 await persist($, c)
317
318 return { text: `goal set: ${c.project.goal.text}` }
319 }
320
321 default:
322 return { text: `unknown: ${word}. ${USAGE}` }
323 }
324 })
325}
326hooks/goal.ts 122 lines1/**
2 * One goal per project: a sentence the person wrote, and a running count of
3 * whether the work since has had anything to do with it.
4 *
5 * "Has anything to do with it" is one meaningful word in common, between the
6 * goal and either an edited path or a commit message. Crude on purpose: it
7 * cannot read intent, it can only tell the person that nothing they touched
8 * has mentioned the goal.
9 */
10export type Goal = {
11 text: string
12 setAt: number
13 words: string[]
14 /** Edits whose path shared a word with the goal, since it was set. */
15 edits: number
16 /** Commits whose message shared a word with the goal, since it was set. */
17 commits: number
18 /** When the work last met the goal. */
19 touchedAt?: number
20}
21
22const FILLER = new Set(
23 (
24 'the and for are but not you your with from that this these those into onto over under ' +
25 'then than they them their have has had been will would could should can may might just ' +
26 'also more most much some any all each every what when where which who how why out off ' +
27 'its our was were get got make made let lets want need keep going about again often ' +
28 'thing things stuff'
29 ).split(' '),
30)
31
32// Words that name where a file lives or what it is, not what it is about.
33const PATH_NOISE = new Set(
34 (
35 'src lib app test tests spec specs index main dist build out ts tsx js jsx mjs cjs json md ' +
36 'py go rs css html yml yaml txt'
37 ).split(' '),
38)
39
40/**
41 * One word, folded: lowercase, a plural taken to its singular, so `pages`
42 * meets `page` and `retries` meets `retry`.
43 */
44function fold(word: string): string {
45 if (word.length > 4 && word.endsWith('ies')) {
46 return `${word.slice(0, -3)}y`
47 }
48
49 if (word.length > 3 && word.endsWith('s') && !/(ss|us|is)$/.test(word)) {
50 return word.slice(0, -1)
51 }
52
53 return word
54}
55
56function wordsOf(text: string): string[] {
57 const spaced = text
58 .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
59 .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
60
61 return spaced
62 .toLowerCase()
63 .split(/[^a-z0-9]+/)
64 .filter(word => word.length >= 3)
65 .map(fold)
66}
67
68/**
69 * The meaningful words of a sentence or a path, folded, each once, in order.
70 */
71export function keywordsOf(text: string): string[] {
72 const seen = new Set<string>()
73
74 for (const word of wordsOf(text)) {
75 if (!FILLER.has(word) && !PATH_NOISE.has(word)) {
76 seen.add(word)
77 }
78 }
79
80 return [...seen]
81}
82
83export function newGoal(text: string, now: number): Goal {
84 return {
85 text: text.trim(),
86 setAt: now,
87 words: keywordsOf(text),
88 edits: 0,
89 commits: 0,
90 }
91}
92
93function meets(goal: Goal, text: string): boolean {
94 if (goal.words.length === 0) {
95 return false
96 }
97
98 const words = new Set(wordsOf(text))
99
100 return goal.words.some(word => words.has(word))
101}
102
103/**
104 * Notes an edit against the goal. `path` is relative to the project root.
105 */
106export function noteEdit(goal: Goal, path: string, now: number): void {
107 if (meets(goal, path.replace(/\.[A-Za-z0-9]{1,5}$/, ''))) {
108 goal.edits += 1
109 goal.touchedAt = now
110 }
111}
112
113/**
114 * Notes a commit against the goal. `message` is the commit message alone.
115 */
116export function noteCommit(goal: Goal, message: string, now: number): void {
117 if (meets(goal, message)) {
118 goal.commits += 1
119 goal.touchedAt = now
120 }
121}
122hooks/paths.ts 122 lines1/**
2 * The dark: edits to tooling, config, docs and plans. Everything else is the
3 * work. One list, so a user can see it and replace it in one place (the
4 * `darkPatterns` plugin option).
5 *
6 * Globs, matched against the path relative to the project root, ignoring
7 * case. `*` stays inside a path segment, `**` crosses them. A pattern with no
8 * slash matches a file name at any depth.
9 */
10export const DEFAULT_DARK_PATTERNS: readonly string[] = [
11 // instructions and agent tooling
12 'CLAUDE.md',
13 'AGENTS.md',
14 '**/.claude/**',
15 // config
16 '*.config.*',
17 'package.json',
18 'tsconfig*',
19 '**/.github/**',
20 // dotfiles
21 '.*',
22 '**/.*/**',
23 // docs and plans
24 'docs/**/*.md',
25 '**/docs/**/*.md',
26 'plan.md',
27 'notes.md',
28 'todo.md',
29 'roadmap.md',
30 'readme.md',
31 // lockfiles
32 '*.lock',
33 '*.lockb',
34 'package-lock.json',
35 'pnpm-lock.yaml',
36 'go.sum',
37]
38
39/**
40 * Reads the `darkPatterns` option: comma or newline separated globs. Empty,
41 * missing or only separators means the built in list; anything else replaces
42 * it.
43 */
44export function parsePatterns(option: unknown): readonly string[] {
45 if (typeof option !== 'string') {
46 return DEFAULT_DARK_PATTERNS
47 }
48
49 const given = option
50 .split(/[,\n]/)
51 .map(part => part.trim())
52 .filter(part => part !== '')
53
54 return given.length === 0 ? DEFAULT_DARK_PATTERNS : given
55}
56
57/**
58 * Makes an absolute path relative to the project root. A path outside the
59 * root (another repository, a home directory file) is returned as it came.
60 */
61export function relativeTo(root: string, path: string): string {
62 const base = root.replaceAll('\\', '/').replace(/\/+$/, '')
63 const full = path.replaceAll('\\', '/')
64
65 if (full === base) {
66 return ''
67 }
68
69 return full.startsWith(`${base}/`) ? full.slice(base.length + 1) : full
70}
71
72/**
73 * A glob as a regular expression. Compiled once per pattern: classifying a
74 * path is a handful of regex tests and no disk work.
75 */
76const compiled = new Map<string, RegExp>()
77
78function regexOf(glob: string): RegExp {
79 const known = compiled.get(glob)
80
81 if (known) {
82 return known
83 }
84
85 let source = ''
86
87 for (let i = 0; i < glob.length; i++) {
88 const char = glob[i] as string
89
90 if (char === '*' && glob[i + 1] === '*') {
91 const isSegment = glob[i + 2] === '/'
92
93 source += isSegment ? '(?:.*/)?' : '.*'
94 i += isSegment ? 2 : 1
95 } else if (char === '*') {
96 source += '[^/]*'
97 } else if (char === '?') {
98 source += '[^/]'
99 } else {
100 source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
101 }
102 }
103
104 // No slash: a file name, wherever it sits.
105 const prefix = glob.includes('/') ? '' : '(?:.*/)?'
106 const regex = new RegExp(`^${prefix}${source}$`, 'i')
107
108 compiled.set(glob, regex)
109
110 return regex
111}
112
113/**
114 * Whether an edit to this path is the dark. `path` is relative to the project
115 * root where it is inside it.
116 */
117export function isDark(path: string, patterns: readonly string[]): boolean {
118 const clean = path.replaceAll('\\', '/').replace(/^\/+/, '')
119
120 return patterns.some(glob => regexOf(glob).test(clean))
121}
122hooks/receipt.ts 202 lines1import { MOVER_LINE } from './mover-line'
2import { dayKeyOf } from './tracker'
3import type { Day, Project } from './tracker'
4
5const DAY_MS = 24 * 60 * 60_000
6const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
7const MONTHS = [
8 'Jan',
9 'Feb',
10 'Mar',
11 'Apr',
12 'May',
13 'Jun',
14 'Jul',
15 'Aug',
16 'Sep',
17 'Oct',
18 'Nov',
19 'Dec',
20]
21
22/** `2h 05m`, `38m`, `<1m`, `0m`. */
23export function formatDuration(ms: number): string {
24 if (ms > 0 && ms < 60_000) {
25 return '<1m'
26 }
27
28 const minutes = Math.floor(ms / 60_000)
29 const hours = Math.floor(minutes / 60)
30
31 return hours > 0
32 ? `${hours}h ${String(minutes % 60).padStart(2, '0')}m`
33 : `${minutes}m`
34}
35
36/** How long ago: `12m ago`, `5h ago`, `3 days ago`. */
37export function formatAgo(ms: number): string {
38 const minutes = Math.floor(Math.max(ms, 0) / 60_000)
39
40 if (minutes < 60) return `${minutes}m ago`
41 if (minutes < 24 * 60) return `${Math.floor(minutes / 60)}h ago`
42
43 const days = Math.floor(minutes / (24 * 60))
44
45 return `${days} ${days === 1 ? 'day' : 'days'} ago`
46}
47
48function dateLabel(date: Date): string {
49 return `${DAYS[date.getDay()]} ${date.getDate()} ${MONTHS[date.getMonth()]}`
50}
51
52const plural = (n: number, one: string, many: string) =>
53 `${n} ${n === 1 ? one : many}`
54
55function shippedOf(day: Day): { total: number; parts: string } {
56 const { commit, push, deploy, test } = day.ships
57 const parts = [
58 commit > 0 ? plural(commit, 'commit', 'commits') : '',
59 push > 0 ? plural(push, 'push', 'pushes') : '',
60 deploy > 0 ? plural(deploy, 'deploy', 'deploys') : '',
61 test > 0 ? plural(test, 'passing test run', 'passing test runs') : '',
62 ].filter(part => part !== '')
63
64 return { total: commit + push + deploy + test, parts: parts.join(', ') }
65}
66
67const row = (label: string, value: string) => ` ${label.padEnd(16)}${value}`
68
69const EMPTY: Day = {
70 activeMs: 0,
71 darkMs: 0,
72 edits: 0,
73 darkEdits: 0,
74 ships: { commit: 0, push: 0, deploy: 0, test: 0 },
75 failed: 0,
76 churn: [],
77}
78
79function goalLines(p: Project, now: number): string[] {
80 const { goal } = p
81
82 if (!goal) {
83 return ['goal: none. /drift goal <text> sets one.']
84 }
85
86 const lines = [`goal: ${goal.text}`]
87 const met: string[] = []
88
89 if (goal.edits > 0) met.push(plural(goal.edits, 'edit', 'edits'))
90 if (goal.commits > 0) met.push(plural(goal.commits, 'commit', 'commits'))
91
92 const when = `set ${formatAgo(now - goal.setAt)}`
93
94 if (goal.words.length === 0) {
95 lines.push(
96 ` ${when}. It has no distinctive words, so nothing can be matched to it.`,
97 )
98 } else if (met.length === 0) {
99 lines.push(
100 ` ${when}. Nothing you edited or committed since has shared a word with it.`,
101 )
102 } else {
103 const last =
104 goal.touchedAt === undefined
105 ? ''
106 : ` The last was ${formatAgo(now - goal.touchedAt)}.`
107
108 lines.push(
109 ` ${when}. ${met.join(' and ')} have shared a word with it.${last}`,
110 )
111 }
112
113 return lines
114}
115
116/**
117 * The receipt `/drift` prints: today, the goal, the last seven days, and the
118 * one line that closes it.
119 */
120export function receiptOf(p: Project, now: number): string {
121 const today = p.days[dayKeyOf(now)] ?? EMPTY
122 const shipped = shippedOf(today)
123 const lines: string[] = []
124
125 if (!p.enabled) {
126 lines.push('drift is off for this project. /drift on turns it back on.', '')
127 }
128
129 lines.push(`drift, today ${dateLabel(new Date(now))}`)
130 lines.push(row('active', formatDuration(today.activeMs)))
131 lines.push(row('since last ship', `${formatDuration(p.live.activeSinceShipMs)} active`))
132 lines.push(
133 row(
134 'shipped',
135 shipped.total === 0 ? '0' : `${shipped.total} (${shipped.parts})`,
136 ),
137 )
138 lines.push(row('in the dark', formatDuration(today.darkMs)))
139 lines.push(row('edits', `work ${today.edits}, dark ${today.darkEdits}`))
140 lines.push(
141 row(
142 'churn files',
143 today.churn.length === 0
144 ? '0'
145 : `${today.churn.length} (${today.churn.join(', ')})`,
146 ),
147 )
148 lines.push(row('failed tries', String(today.failed)))
149 lines.push('')
150 lines.push(...goalLines(p, now))
151 lines.push('')
152 lines.push('last 7 days')
153
154 const rows: string[] = []
155 const total = { activeMs: 0, darkMs: 0, shipped: 0, churn: 0 }
156 const base = new Date(now)
157
158 for (let back = 0; back < 7; back++) {
159 const date = new Date(
160 base.getFullYear(),
161 base.getMonth(),
162 base.getDate() - back,
163 12,
164 )
165 const day = p.days[dayKeyOf(date.getTime())]
166
167 if (!day) continue
168
169 const count = shippedOf(day).total
170
171 total.activeMs += day.activeMs
172 total.darkMs += day.darkMs
173 total.shipped += count
174 total.churn += day.churn.length
175 rows.push(
176 ` ${dateLabel(date).padEnd(11)} ` +
177 `active ${formatDuration(day.activeMs).padEnd(7)} ` +
178 `shipped ${String(count).padEnd(3)} ` +
179 `dark ${formatDuration(day.darkMs).padEnd(7)} ` +
180 `churn ${day.churn.length}`,
181 )
182 }
183
184 if (rows.length === 0) {
185 lines.push(' nothing recorded yet')
186 } else {
187 lines.push(...rows)
188 lines.push(
189 ` ${'all 7'.padEnd(11)} ` +
190 `active ${formatDuration(total.activeMs).padEnd(7)} ` +
191 `shipped ${String(total.shipped).padEnd(3)} ` +
192 `dark ${formatDuration(total.darkMs).padEnd(7)} ` +
193 `churn ${total.churn}`,
194 )
195 }
196
197 lines.push('')
198 lines.push(MOVER_LINE)
199
200 return lines.join('\n')
201}
202hooks/ships.ts 365 lines1export type ShipKind = 'commit' | 'push' | 'deploy' | 'test'
2
3export type ShipHit = {
4 kind: ShipKind
5 /**
6 * The text of the command that made the hit, quotes and all. A commit's
7 * message is in it; the goal check reads it.
8 */
9 text: string
10}
11
12/**
13 * Cuts a shell command line into its commands: at `&&`, `||`, `;`, `|`, `&`
14 * and newlines, never inside quotes, and with the body of an unquoted heredoc
15 * dropped (it is text for the command above it, not a command).
16 *
17 * Not a shell parser. It is enough to tell `git commit -m "a; b"` (one
18 * command) from `git commit -m a; git push` (two).
19 */
20function segmentsOf(line: string): string[] {
21 const out: string[] = []
22 let current = ''
23 let quote: '"' | "'" | '`' | null = null
24 let i = 0
25
26 const flush = () => {
27 if (current.trim() !== '') {
28 out.push(current.trim())
29 }
30 current = ''
31 }
32
33 while (i < line.length) {
34 const char = line[i] as string
35 const next = line[i + 1]
36
37 if (quote) {
38 current += char
39
40 if (char === '\\' && quote !== "'" && next !== undefined) {
41 current += next
42 i += 2
43 continue
44 }
45
46 if (char === quote) {
47 quote = null
48 }
49
50 i++
51 continue
52 }
53
54 if (char === '\\' && next !== undefined) {
55 current += char + next
56 i += 2
57 continue
58 }
59
60 if (char === '"' || char === "'" || char === '`') {
61 quote = char
62 current += char
63 i++
64 continue
65 }
66
67 if (char === '<' && next === '<' && line[i + 2] !== '<') {
68 const heredoc = /^<<(-?)\s*(['"]?)([A-Za-z_][\w-]*)\2/.exec(line.slice(i))
69
70 if (heredoc) {
71 const [whole, dash, , word] = heredoc as unknown as [
72 string,
73 string,
74 string,
75 string,
76 ]
77 const restOfLine = line.indexOf('\n', i)
78
79 current += whole
80 i += whole.length
81
82 if (restOfLine === -1) {
83 continue
84 }
85
86 // Keep what follows the delimiter on the same line, then skip the body.
87 const tail = line.slice(i, restOfLine)
88 current += tail
89 flush()
90
91 let at = restOfLine + 1
92
93 while (at < line.length) {
94 const end = line.indexOf('\n', at)
95 const row = line.slice(at, end === -1 ? line.length : end)
96 const isEnd = (dash ? row.trim() : row) === word
97
98 at = end === -1 ? line.length : end + 1
99
100 if (isEnd) {
101 break
102 }
103 }
104
105 i = at
106 continue
107 }
108 }
109
110 if (char === '\n' || char === ';') {
111 flush()
112 i++
113 continue
114 }
115
116 if (char === '&' || char === '|') {
117 flush()
118 i += next === char ? 2 : 1
119 continue
120 }
121
122 current += char
123 i++
124 }
125
126 flush()
127
128 return out
129}
130
131/**
132 * A segment as words, quotes removed from around each. `git commit -m "a b"`
133 * is `git`, `commit`, `-m`, `a b`.
134 */
135function wordsOf(segment: string): string[] {
136 const words: string[] = []
137 let current = ''
138 let has = false
139 let quote: '"' | "'" | null = null
140
141 for (let i = 0; i < segment.length; i++) {
142 const char = segment[i] as string
143
144 if (quote) {
145 if (char === quote) {
146 quote = null
147 } else {
148 current += char
149 }
150 continue
151 }
152
153 if (char === '"' || char === "'") {
154 quote = char
155 has = true
156 } else if (/\s/.test(char)) {
157 if (has || current !== '') {
158 words.push(current)
159 }
160 current = ''
161 has = false
162 } else {
163 current += char
164 has = true
165 }
166 }
167
168 if (has || current !== '') {
169 words.push(current)
170 }
171
172 return words
173}
174
175const WRAPPERS = new Set(['time', 'sudo', 'nice', 'command', 'env', 'exec'])
176const RUNNERS = new Set(['npx', 'bunx'])
177const RUNNER_PAIRS: ReadonlyArray<readonly [string, string]> = [
178 ['pnpm', 'exec'],
179 ['pnpm', 'dlx'],
180 ['yarn', 'dlx'],
181 ['uv', 'run'],
182 ['poetry', 'run'],
183 ['python', '-m'],
184 ['python3', '-m'],
185]
186
187/**
188 * What the command is, with env assignments, `sudo`-like wrappers, a leading
189 * `(` and runners (`npx`, `uv run`, `python -m`) taken off the front.
190 */
191function headOf(words: string[]): string[] {
192 let rest = words.slice()
193
194 for (let guard = 0; guard < 8; guard++) {
195 const first = (rest[0] ?? '').replace(/^[({]+/, '')
196
197 if (first === '') {
198 rest = rest.slice(1)
199 if (rest.length === 0) break
200 continue
201 }
202
203 rest[0] = first
204
205 if (/^[A-Za-z_]\w*=/.test(first) || WRAPPERS.has(first)) {
206 rest = rest.slice(1)
207 continue
208 }
209
210 if (RUNNERS.has(first)) {
211 rest = rest.slice(1)
212 while ((rest[0] ?? '').startsWith('-')) rest = rest.slice(1)
213 continue
214 }
215
216 const pair = RUNNER_PAIRS.find(
217 ([a, b]) => a === first && rest[1] === b,
218 )
219
220 if (pair) {
221 rest = rest.slice(2)
222 continue
223 }
224
225 break
226 }
227
228 return rest
229}
230
231// git options that take a value in the next word.
232const GIT_VALUE_OPTIONS = new Set([
233 '-C',
234 '-c',
235 '--git-dir',
236 '--work-tree',
237 '--namespace',
238 '--exec-path',
239])
240
241function gitSubcommandOf(words: string[]): string | undefined {
242 for (let i = 1; i < words.length; i++) {
243 const word = words[i] as string
244
245 if (GIT_VALUE_OPTIONS.has(word)) {
246 i++
247 } else if (!word.startsWith('-')) {
248 return word
249 }
250 }
251
252 return undefined
253}
254
255function firstPlainWord(words: string[], from: number): string | undefined {
256 return words.slice(from).find(word => !word.startsWith('-'))
257}
258
259function kindOf(words: string[]): ShipKind | undefined {
260 const head = headOf(words)
261 const name = head[0]
262
263 if (name === undefined) {
264 return undefined
265 }
266
267 const isDry = head.some(word => word === '--dry-run')
268
269 switch (name) {
270 case 'git': {
271 const sub = gitSubcommandOf(head)
272
273 if (sub === 'commit' && !isDry) return 'commit'
274 if (sub === 'push' && !isDry && !head.includes('-n')) return 'push'
275
276 return undefined
277 }
278 case 'vercel': {
279 const sub = firstPlainWord(head, 1)
280
281 return sub === undefined || sub === 'deploy' ? 'deploy' : undefined
282 }
283 case 'wrangler':
284 return (head.includes('deploy') || head.includes('publish')) && !isDry
285 ? 'deploy'
286 : undefined
287 case 'fly':
288 case 'flyctl':
289 case 'netlify':
290 return firstPlainWord(head, 1) === 'deploy' ? 'deploy' : undefined
291 case 'npm':
292 case 'pnpm':
293 case 'yarn':
294 case 'bun': {
295 const sub = head[1]
296
297 if (sub === 'publish') return isDry ? undefined : 'deploy'
298 if (sub === 'test' || sub === 't') return 'test'
299
300 if (sub === 'run' || sub === 'run-script') {
301 return /^test(:|$)/.test(head[2] ?? '') ? 'test' : undefined
302 }
303
304 return undefined
305 }
306 case 'pytest':
307 case 'py.test':
308 case 'vitest':
309 case 'jest':
310 return 'test'
311 case 'go':
312 case 'cargo':
313 return head[1] === 'test' ? 'test' : undefined
314 default:
315 return undefined
316 }
317}
318
319/**
320 * The ships a Bash command line holds, in order: commits, pushes, deploys and
321 * test runs. A line with no ship is an empty list. Whether the line then
322 * exited 0 is the caller's to say, from the tool's result.
323 */
324export function shipsIn(command: string): ShipHit[] {
325 const hits: ShipHit[] = []
326
327 for (const segment of segmentsOf(command)) {
328 const kind = kindOf(wordsOf(segment))
329
330 if (kind) {
331 hits.push({ kind, text: segment })
332 }
333 }
334
335 return hits
336}
337
338/**
339 * The message of a `git commit` command: what follows each `-m`, `-am` or
340 * `--message`, joined with a space. Empty when the commit took its message
341 * from elsewhere (`--no-edit`, `-F`, the editor).
342 */
343export function commitMessageOf(text: string): string {
344 const words = wordsOf(text)
345 const parts: string[] = []
346
347 for (let i = 0; i < words.length; i++) {
348 const word = words[i] as string
349 const inline = /^--message=(.*)$/s.exec(word)
350
351 if (inline) {
352 parts.push(inline[1] as string)
353 } else if (/^-[A-Za-z]*m$/.test(word) || word === '--message') {
354 const value = words[i + 1]
355
356 if (value !== undefined) {
357 parts.push(value)
358 i++
359 }
360 }
361 }
362
363 return parts.join(' ')
364}
365hooks/tracker.ts 374 lines1import { commitMessageOf } from './ships'
2import type { ShipHit, ShipKind } from './ships'
3import { noteCommit, noteEdit } from './goal'
4import type { Goal } from './goal'
5
6/** A gap longer than this between two events is idle time, never counted. */
7export const IDLE_GAP_MS = 10 * 60_000
8/** Active minutes since a ship before the band speaks. */
9export const TIME_WARN_MS = 45 * 60_000
10/** How many of the latest edits the dark share looks at. */
11export const DARK_WINDOW = 20
12/** Fewest edits in the window before a dark share means anything. */
13export const DARK_MIN_EDITS = 10
14/** The share of the window that must be dark for the band to speak. */
15export const DARK_SHARE = 0.6
16/** Edits to one file, with nothing shipped between, that make it churn. */
17export const CHURN_EDITS = 4
18/** Days of history kept. */
19export const KEEP_DAYS = 30
20/** A break this long starts a fresh run: counters since a ship start over. */
21export const FRESH_RUN_MS = 8 * 60 * 60_000
22
23// Bounds, so one hook call stays small however long the session runs.
24const MAX_TRACKED_FILES = 500
25const MAX_CHURN_PER_DAY = 20
26
27export type Day = {
28 activeMs: number
29 /** Active time whose closing event was an edit to a dark file. */
30 darkMs: number
31 /** Edits to the work. */
32 edits: number
33 /** Edits to the dark. */
34 darkEdits: number
35 ships: Record<ShipKind, number>
36 /** Ship attempts that did not succeed. */
37 failed: number
38 /** Files that crossed the churn line this day. */
39 churn: string[]
40}
41
42export type Live = {
43 lastAt?: number
44 /** Active time since the last successful ship. */
45 activeSinceShipMs: number
46 /** The latest edits, oldest first: 1 for the dark, 0 for the work. */
47 window: number[]
48 /** Edits per file since the last successful ship. */
49 fileEdits: Record<string, number>
50 announced: { time: boolean; dark: boolean; churn: string[] }
51 /** The line the band shows now, or null while it is silent. */
52 band: string | null
53}
54
55export type Project = {
56 enabled: boolean
57 goal?: Goal
58 days: Record<string, Day>
59 live: Live
60}
61
62export function emptyLive(): Live {
63 return {
64 activeSinceShipMs: 0,
65 window: [],
66 fileEdits: {},
67 announced: { time: false, dark: false, churn: [] },
68 band: null,
69 }
70}
71
72function emptyDay(): Day {
73 return {
74 activeMs: 0,
75 darkMs: 0,
76 edits: 0,
77 darkEdits: 0,
78 ships: { commit: 0, push: 0, deploy: 0, test: 0 },
79 failed: 0,
80 churn: [],
81 }
82}
83
84export function emptyProject(): Project {
85 return { enabled: true, days: {}, live: emptyLive() }
86}
87
88function isRecord(value: unknown): value is Record<string, unknown> {
89 return typeof value === 'object' && value !== null && !Array.isArray(value)
90}
91
92/**
93 * A project as the store held it, made whole: a field an older version never
94 * wrote, or a store edited by hand, falls back to its empty value. Never
95 * throws.
96 */
97export function reviveProject(raw: unknown): Project {
98 const fresh = emptyProject()
99
100 if (!isRecord(raw)) {
101 return fresh
102 }
103
104 const days: Record<string, Day> = {}
105
106 if (isRecord(raw.days)) {
107 for (const [key, value] of Object.entries(raw.days)) {
108 if (isRecord(value)) {
109 const base = emptyDay()
110
111 days[key] = {
112 ...base,
113 ...value,
114 ships: { ...base.ships, ...(isRecord(value.ships) ? value.ships : {}) },
115 churn: Array.isArray(value.churn) ? (value.churn as string[]) : [],
116 }
117 }
118 }
119 }
120
121 const live = isRecord(raw.live) ? raw.live : {}
122 const base = emptyLive()
123
124 return {
125 enabled: raw.enabled !== false,
126 goal: isRecord(raw.goal) ? (raw.goal as unknown as Goal) : undefined,
127 days,
128 live: {
129 ...base,
130 ...live,
131 window: Array.isArray(live.window) ? (live.window as number[]) : [],
132 fileEdits: isRecord(live.fileEdits)
133 ? (live.fileEdits as Record<string, number>)
134 : {},
135 announced: {
136 ...base.announced,
137 ...(isRecord(live.announced) ? live.announced : {}),
138 },
139 },
140 }
141}
142
143/**
144 * A moment as its local calendar day, `2026-10-01`.
145 */
146export function dayKeyOf(ms: number): string {
147 const d = new Date(ms)
148 const pad = (n: number) => String(n).padStart(2, '0')
149
150 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
151}
152
153function dayOf(p: Project, at: number): Day {
154 const key = dayKeyOf(at)
155 let day = p.days[key]
156
157 if (!day) {
158 day = emptyDay()
159 p.days[key] = day
160
161 // The day is new: the one time history is looked at, to drop what is old.
162 const oldest = dayKeyOf(at - KEEP_DAYS * 24 * 60 * 60_000)
163
164 for (const known of Object.keys(p.days)) {
165 if (known < oldest) {
166 delete p.days[known]
167 }
168 }
169 }
170
171 return day
172}
173
174/**
175 * Moves the active clock to `at`. A gap of ten minutes or less is active
176 * time; a longer one is idle and counts for nothing.
177 */
178function advance(p: Project, at: number, isDark: boolean): void {
179 const last = p.live.lastAt
180
181 p.live.lastAt = at
182
183 const day = dayOf(p, at)
184
185 if (last === undefined || at <= last || at - last > IDLE_GAP_MS) {
186 return
187 }
188
189 const gap = at - last
190
191 p.live.activeSinceShipMs += gap
192 day.activeMs += gap
193
194 if (isDark) {
195 day.darkMs += gap
196 }
197}
198
199/**
200 * The sentences for every threshold the project has crossed since the band
201 * last spoke about it. A threshold speaks once per crossing: it is armed again
202 * when the thing it measures drops back (a ship, a better edit mix).
203 */
204function crossings(p: Project, churned: string | undefined): string[] {
205 const { live } = p
206 const said: string[] = []
207
208 if (live.activeSinceShipMs >= TIME_WARN_MS) {
209 if (!live.announced.time) {
210 live.announced.time = true
211 said.push(
212 `${Math.floor(live.activeSinceShipMs / 60_000)} min since anything shipped.`,
213 )
214 }
215 } else {
216 live.announced.time = false
217 }
218
219 const dark = live.window.reduce((sum, flag) => sum + flag, 0)
220 const isDarkHigh =
221 live.window.length >= DARK_MIN_EDITS &&
222 dark / live.window.length >= DARK_SHARE
223
224 if (isDarkHigh) {
225 if (!live.announced.dark) {
226 live.announced.dark = true
227 said.push(
228 `${dark} of your last ${live.window.length} edits were config and docs.`,
229 )
230 }
231 } else {
232 live.announced.dark = false
233 }
234
235 if (churned !== undefined) {
236 const count = live.fileEdits[churned] ?? 0
237
238 if (count >= CHURN_EDITS && !live.announced.churn.includes(churned)) {
239 live.announced.churn.push(churned)
240 said.push(`${churned} has been edited ${count} times with nothing shipped.`)
241 }
242 }
243
244 return said
245}
246
247function raise(p: Project, said: string[]): string | null {
248 if (said.length === 0) {
249 return null
250 }
251
252 const line = said.join(' ')
253
254 p.live.band = p.live.band === null ? line : `${p.live.band} ${line}`
255
256 return line
257}
258
259/**
260 * Any tool call, prompt or finished turn: the session was alive at `at`.
261 * Returns the sentences the band should now add, or null.
262 */
263export function recordActivity(p: Project, at: number): string | null {
264 advance(p, at, false)
265
266 return raise(p, crossings(p, undefined))
267}
268
269/**
270 * A successful edit. `path` is relative to the project root; `isDark` says
271 * whether it is tooling, config, docs or plans.
272 */
273export function recordEdit(
274 p: Project,
275 at: number,
276 path: string,
277 isDark: boolean,
278): string | null {
279 advance(p, at, isDark)
280
281 const { live } = p
282 const day = dayOf(p, at)
283
284 if (isDark) {
285 day.darkEdits += 1
286 } else {
287 day.edits += 1
288 }
289
290 live.window.push(isDark ? 1 : 0)
291
292 if (live.window.length > DARK_WINDOW) {
293 live.window.shift()
294 }
295
296 const isKnown = path in live.fileEdits
297
298 if (isKnown || Object.keys(live.fileEdits).length < MAX_TRACKED_FILES) {
299 live.fileEdits[path] = (live.fileEdits[path] ?? 0) + 1
300 }
301
302 if (
303 (live.fileEdits[path] ?? 0) >= CHURN_EDITS &&
304 !day.churn.includes(path) &&
305 day.churn.length < MAX_CHURN_PER_DAY
306 ) {
307 day.churn.push(path)
308 }
309
310 if (p.goal) {
311 noteEdit(p.goal, path, at)
312 }
313
314 return raise(p, crossings(p, path))
315}
316
317/**
318 * A ship attempt: a commit, push, deploy or test run that the Bash command
319 * held. `isOk` is whether the command exited 0. A failure is counted as an
320 * attempt and changes nothing else; a success resets everything measured
321 * "since a ship" and silences the band.
322 */
323export function recordShip(
324 p: Project,
325 at: number,
326 hit: ShipHit,
327 isOk: boolean,
328): null {
329 advance(p, at, false)
330
331 const day = dayOf(p, at)
332
333 if (!isOk) {
334 day.failed += 1
335
336 return null
337 }
338
339 day.ships[hit.kind] += 1
340
341 p.live.activeSinceShipMs = 0
342 p.live.fileEdits = {}
343 p.live.announced = { time: false, dark: p.live.announced.dark, churn: [] }
344 p.live.band = null
345
346 if (hit.kind === 'commit' && p.goal) {
347 noteCommit(p.goal, commitMessageOf(hit.text), at)
348 }
349
350 return null
351}
352
353/** The next prompt has been sent: the band has been seen, it goes quiet. */
354export function clearBand(p: Project): void {
355 p.live.band = null
356}
357
358/**
359 * A session begins. The band starts silent. After a long break the run is
360 * fresh: nothing carried from yesterday's edits counts against today.
361 */
362export function startSession(p: Project, at: number): void {
363 p.live.band = null
364
365 const last = p.live.lastAt
366
367 if (last !== undefined && at - last > FRESH_RUN_MS) {
368 const { band } = p.live
369
370 p.live = emptyLive()
371 p.live.band = band
372 }
373}
374hooks/mover-line.ts 6 lines1/**
2 * The one line that closes `/drift`, and never appears anywhere else. A
3 * single constant, in this single file.
4 */
5export const MOVER_LINE = "drift only sees your edits. mover remembers what you said you'd do and checks it against what you did: moveros.dev"
6hooks/state.d.ts 12 lines1/**
2 * What the band shows, held by the host for the session so a redraw reads it:
3 * the line, or null while the band is silent.
4 */
5export type DriftBand = string | null
6
7declare module 'claude-code' {
8 interface PluginState {
9 drift: { band: DriftBand }
10 }
11}
12