logs merges, releases and deploys across sessions and drafts build-in-public posts

hooks/register.ts 45 lines1import type { Register } from 'claude-code'
2
3import { classify, draftPrompt, formatLog, since, toEntry } from './ship'
4import type { Entry } from './ship'
5
6const STORE_KEY = 'entries'
7const MAX_ENTRIES = 1000
8const DEFAULT_DAYS = 7
9const DRAFT_MODEL = 'sonnet'
10
11export const register: Register = on => {
12 on('session.start', async ($, e, next) => {
13 await $.command.register({
14 name: 'shipped',
15 description: 'Show what you shipped and draft an X post (optional: days, default 7)',
16 })
17 return next(e)
18 })
19
20 on('tool.call', async ($, e, next) => {
21 const ran = await next(e)
22 const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
23 const kind = command && classify(command)
24 if (!kind || ran.deny !== undefined || ran.isError) return ran
25
26 const entry = toEntry(await $.clock.now(), await $.session.cwd(), kind, command, ran.text ?? '')
27 const entries = ((await $.store.get(STORE_KEY)) as Entry[] | undefined) ?? []
28 await $.store.set(STORE_KEY, [...entries, entry].slice(-MAX_ENTRIES))
29 $.ui.toast(`shiplog: ${kind} in ${entry.repo}`)
30 return ran
31 })
32
33 on('command.run', { command: 'shipped' }, async ($, e) => {
34 const days = Number(e.args.trim()) || DEFAULT_DAYS
35 const entries = ((await $.store.get(STORE_KEY)) as Entry[] | undefined) ?? []
36 const recent = since(entries, await $.clock.now(), days)
37 if (recent.length === 0) return { text: `Nothing logged in the last ${days} days yet.` }
38
39 const log = formatLog(recent)
40 const draft = await $.model.complete({ model: DRAFT_MODEL, prompt: draftPrompt(log, days) })
41 const post = draft.isAnswered ? draft.text : `(draft failed: ${draft.reason})`
42 return { text: `Shipped in the last ${days} days:\n${log}\n\n--- draft ---\n${post}` }
43 })
44}
45hooks/ship.ts 47 lines1export type Kind = 'pr' | 'merge' | 'release' | 'deploy'
2
3export type Entry = {
4 at: number
5 repo: string
6 kind: Kind
7 command: string
8 output: string
9}
10
11const RULES: [Kind, RegExp][] = [
12 ['merge', /\bgh pr merge\b/],
13 ['release', /\bgh release create\b/],
14 ['pr', /\bgh pr create\b/],
15 ['deploy', /\bwrangler (pages )?deploy\b|\bdeploy(\.ts|\.sh|\.js)?\b.*--(prod|production|staging)\b|\bvercel --prod\b/],
16]
17
18const DAY_MS = 86_400_000
19const MAX_COMMAND = 200
20const MAX_OUTPUT = 300
21
22export const classify = (command: string): Kind | null =>
23 RULES.find(([, rule]) => rule.test(command))?.[0] ?? null
24
25export const toEntry = (at: number, cwd: string, kind: Kind, command: string, output: string): Entry => ({
26 at,
27 repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd,
28 kind,
29 command: command.slice(0, MAX_COMMAND),
30 output: output.trim().split('\n').slice(0, 3).join(' ').slice(0, MAX_OUTPUT),
31})
32
33export const since = (entries: Entry[], now: number, days: number) =>
34 entries.filter(e => e.at >= now - days * DAY_MS)
35
36export const formatLog = (entries: Entry[]) =>
37 entries
38 .map(e => `${new Date(e.at).toISOString().slice(0, 10)} ${e.repo} ${e.kind}: ${e.output || e.command}`)
39 .join('\n')
40
41export const draftPrompt = (log: string, days: number) => `You write build-in-public posts for X for a solo dev who ships with Claude Code.
42
43Here is everything they shipped in the last ${days} days (date, repo, kind, details):
44${log}
45
46Write one X post (under 280 chars) and one 3-5 tweet thread. Voice: casual, lowercase, plain words, specific (name repos and what changed), no hype words, no hashtags, no em dashes. Skip dependabot and chore noise unless it's all there is. Output the post, a blank line, then the thread with tweets numbered 1/, 2/ and so on.`
47