SLOPSHOPPER

streak

Consecutive days with a completed turn, in the status line; toasts at 7, 30, 100 and 365 days; /streak summary

newcommandtoaststatus
v0.1.0MITupdated 2026-10-05vincentlauriat/ModsTools/mods/streak
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · streak
› 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 › /streak ⎿ streak: Current streak: 1 day ⎿ streak: Longest streak: 1 ⎿ streak: Active days: 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ streak: 🔥 1 day
README

streak

Counts consecutive days on which you completed at least one turn.

What it does

  • Records the local calendar day after each completed main-agent turn; the day list is kept across sessions (last 400 days).
  • Status line: 🔥 N-day streak (🔥 1 day for a single day); hidden when the streak is 0.
  • A streak stays alive if its last day is today or yesterday.
  • Toast 🔥 N-day streak! when a turn crosses 7, 30, 100 or 365 days.

Commands

CommandEffect
/streakShows current streak, longest streak and total active days

Install

claude --plugin-dir /path/to/ModsTools/mods/streak

Limits

  • Days follow the local clock and time zone of the machine.
  • Only the last 400 active days are stored, so a longest streak beyond that is truncated.

Develop

claude plugin validate mods/streak
claude plugin test mods/streak   # 13 tests
Source 2 files
hooks/register.ts 45 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { addDay, crossedMilestone, dayKey, stats, statusLine, summary } from './days'
4
5const KEY = 'days'
6
7async function load($: EngineInterface): Promise<string[]> {
8  const value = await $.store.get(KEY)
9
10  return Array.isArray(value) ? (value as string[]) : []
11}
12
13async function show($: EngineInterface, days: string[]): Promise<void> {
14  $.ui.status(statusLine(stats(days, dayKey(await $.clock.now())).current))
15}
16
17export const register: Register = on => {
18  on('session.start', async ($, e, next) => {
19    await $.command.register({ name: 'streak', description: 'Streak of consecutive active days: current, longest, total' })
20    await show($, await load($))
21
22    return next(e)
23  })
24
25  on('turn.complete', async ($, e, next) => {
26    if (e.agentId === undefined) {
27      const today = dayKey(await $.clock.now())
28      const days = await load($)
29      const before = stats(days, today).current
30      const updated = addDay(days, today)
31      const after = stats(updated, today).current
32      await $.store.set(KEY, updated)
33      await show($, updated)
34      const milestone = crossedMilestone(before, after)
35      if (milestone !== undefined) $.ui.toast(`🔥 ${milestone}-day streak!`)
36    }
37
38    return next(e)
39  })
40
41  on('command.run', { command: 'streak' }, async $ => ({
42    text: summary(stats(await load($), dayKey(await $.clock.now()))),
43  }))
44}
45
hooks/days.ts 57 lines
1export const KEEP_DAYS = 400
2export const MILESTONES = [7, 30, 100, 365]
3
4export type Stats = { current: number; longest: number; total: number }
5
6export function dayKey(ms: number): string {
7  const d = new Date(ms)
8
9  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
10}
11
12// Calendar day number, DST-proof: computed from the key's y/m/d in UTC.
13export function dayNumber(key: string): number {
14  const [y = 0, m = 1, d = 1] = key.split('-').map(Number)
15
16  return Date.UTC(y, m - 1, d) / 86_400_000
17}
18
19export function addDay(days: string[], key: string): string[] {
20  const set = new Set(days)
21  set.add(key)
22
23  return [...set].sort().slice(-KEEP_DAYS)
24}
25
26export function stats(days: string[], today: string): Stats {
27  const nums = [...new Set(days.map(dayNumber))].sort((a, b) => a - b)
28  let longest = 0
29  let run = 0
30  let prev = Number.NaN
31  for (const n of nums) {
32    run = n === prev + 1 ? run + 1 : 1
33    longest = Math.max(longest, run)
34    prev = n
35  }
36  // The streak is still alive when its last day is today or yesterday.
37  const last = nums[nums.length - 1]
38  const t = dayNumber(today)
39  const current = last !== undefined && (last === t || last === t - 1) ? run : 0
40
41  return { current, longest, total: nums.length }
42}
43
44export function crossedMilestone(before: number, after: number): number | undefined {
45  return [...MILESTONES].reverse().find(m => before < m && after >= m)
46}
47
48export function statusLine(current: number): string | undefined {
49  if (current <= 0) return undefined
50
51  return current === 1 ? '🔥 1 day' : `🔥 ${current}-day streak`
52}
53
54export function summary(s: Stats): string {
55  return [`Current streak: ${s.current} day${s.current === 1 ? '' : 's'}`, `Longest streak: ${s.longest}`, `Active days: ${s.total}`].join('\n')
56}
57