SLOPSHOPPER

drift

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…

newbandguardcommandprompt
v0.1.0MITupdated 2026-10-01azkhh/drift
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · drift
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /drift ⎿ drift: drift, today Thu 9 Oct ⎿ drift: active <1m ⎿ drift: since last ship <1m active ⎿ drift: shipped 1 (1 push) ⎿ drift: in the dark 0m ⎿ drift: edits work 3, dark 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

drift

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).

Install

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

What the band says

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).

ThresholdLine
45 active minutes since anything shipped52 min since anything shipped.
60% or more of your last 20 edits (at least 10 edits) went to the dark14 of your last 20 edits were config and docs.
The same file edited 4 times with nothing shipped betweensrc/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

CommandWhat it does
/driftToday'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 goalShows the goal. /drift goal clear removes it.
/drift off, /drift onStop or resume counting in this project.
/drift resetClears this project's counters and history. Keeps the goal and the on/off switch.

The work and the dark

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.

What the mod events can and cannot see

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.

  • A chain has one exit status. 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.
  • Edits made through Bash (sed -i, echo >> file, a formatter) are not edits to the mod. Only the edit tools are.
  • Ships it does not recognise: a release script (npm run release), a deploy from CI, a commit you make in another terminal.
  • It does not see time spent thinking or typing. Active time is the sum of gaps of 10 minutes or less between tool calls, prompts and finished turns.
  • A passing test run counts as a ship, because it is evidence of moving. It resets the clock since a ship like a commit does.

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.

Privacy

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.

Development

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:

FileWhat it holds
hooks/paths.tsThe dark list, the globs, the option
hooks/ships.tsFinding commits, pushes, deploys and test runs in a command line
hooks/goal.tsThe goal and its shared word check
hooks/tracker.tsActive time, the edit window, churn, ships, day totals, the three thresholds
hooks/receipt.tsThe text /drift prints
hooks/mover-line.tsThe one line that ends /drift
hooks/register.tsThe 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.

License

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

Source 8 files
hooks/register.ts 326 lines
1import 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}
326
hooks/goal.ts 122 lines
1/**
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}
122
hooks/paths.ts 122 lines
1/**
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}
122
hooks/receipt.ts 202 lines
1import { 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}
202
hooks/ships.ts 365 lines
1export 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}
365
hooks/tracker.ts 374 lines
1import { 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}
374
hooks/mover-line.ts 6 lines
1/**
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"
6
hooks/state.d.ts 12 lines
1/**
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