When one of your PRs merges, drafts the documentation update and opens it as a PR once you approve

A Claude Code mod that keeps documentation in step with merged code. When one of your pull requests merges, it drafts the documentation update in a worktree of the main branch, shows it to you in Claude Code, and opens it as a pull request once you approve.
/plugin install docs-watch --marketplace Elmanuel1/claude-mods
Answer y to add the marketplace, then pick a scope.
It needs git, the GitHub CLI (gh) logged in to github.com, and the Claude Code CLI (claude) on PATH. When a session starts, before each 5-minute check, and whenever a git or gh command fails, the mod checks each one and whether gh's login can reach and push to the session's repository. Anything missing pops up as a toast naming it and the command that fixes it:
| Missing | Fix shown | |
|---|---|---|
git | brew install git (macOS; without Homebrew, xcode-select --install), or https://git-scm.com/downloads | |
gh | brew install gh (macOS), or https://cli.github.com | |
gh not logged in | gh auth login --hostname github.com | |
claude | `curl -fsSL https://claude.ai/install.sh \ | bash` |
| repository not reachable or read-only | gh auth refresh --hostname github.com --scopes repo, or log in as an account with write access |
Each popup shows once per session, and again only when an action fails for that reason (at most once a minute). A missing git or gh, or gh not logged in, also keeps a line above the prompt with a Check again button, and nothing polls or drafts until it passes; the pane lists everything still missing under Setup needed. The checks read only exit codes and the repository permission, so no token is ever shown.
~/.claude/docs-watch/work/, and a subagent runs the bundled docs-watch:update-docs skill there. It edits only documentation files, following the repository's own CLAUDE.md, AGENTS.md or CONTRIBUTING.md. Nothing is committed. You can also run the skill yourself: /docs-watch:update-docs 848./docs-watch (or Review) opens the list: the summary, the diff, Approve, Dismiss, Approve all when more than one is waiting, and Redo with a note, which drafts it again with your comment (for example "also update the README"). Where the pane has no text field, /docs-watch redo <owner/repo#number> <note> does the same.docs/updates. Within 15 seconds the session that holds the poll lease lands them on the branch, one at a time; when one conflicts with an update already there, the docs-watch:docs-merge agent resolves the doc files so both updates' content stays, and the queue carries on. Once the queue is empty the mod pushes the branch with git and opens one pull request for every approved update with gh pr create, or updates the one already open with gh pr edit. Its title is built from the branch's commit subjects. The line above the prompt then says "Docs PR updated" with Open PR and Hide; a failed push shows its reason with Retry. It never pushes to the default branch and never merges: the pull request is where you review the combined change.terminal-notifier installed (brew install terminal-notifier), a newer one replaces the last instead of stacking.git or gh command, refused subagent start and unreadable result file goes to the debug log and to the pane's Recent errors list. A draft or conflict resolution whose subagent the engine refused to start waits, and the next turn, /docs-watch or session start tries again.git and gh only.hooks/register.tsx 285 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register } from 'claude-code'
3
4import type { Missing, Status, Tracked } from '../types'
5import { bandIsEmpty, bandView } from './band'
6import type { BandActions, BandModel } from './band'
7import { advanceQueues, approve, clearPushErrors } from './branch'
8import { dismiss, redo } from './draft'
9import type { Host, Kit } from './host'
10import { clearErrors, logError, recentErrors, runLogged } from './log'
11import { paneView } from './pane'
12import type { PaneActions } from './pane'
13import { startPending, tick } from './poll'
14import { blocking, checkRequirements, describeMissing, dueForPopup, popupText } from './requirements'
15import { branchState, branchStates, registerRepo, saveBranchState, toplevel, tracked, visible } from './store'
16import { describe } from './text'
17
18const PANE = 'docs-watch'
19const TICK_MS = 5 * 60 * 1000
20const FIRST_TICK_MS = 20 * 1000
21const RESOLVE_CHECK_MS = 15 * 1000
22const RECHECK_MS = 30 * 1000
23const POPUP_MS = 15 * 1000
24
25const shown = atom({ plugin: 'docs-watch', key: 'shown' } as const, null)
26const diff = atom({ plugin: 'docs-watch', key: 'diff' } as const, null)
27const setup = atom({ plugin: 'docs-watch', key: 'setup' } as const, null)
28const errors = atom({ plugin: 'docs-watch', key: 'errors' } as const, null)
29const branches = atom({ plugin: 'docs-watch', key: 'branches' } as const, null)
30const popups = atom({ plugin: 'docs-watch', key: 'popups' } as const, null)
31
32// The checkout this session runs in, for registering its repository and checking access to it; set at session start.
33let sessionCheckout: string | undefined
34let lastRecheck = Number.NEGATIVE_INFINITY
35
36function host($: Engine): Host {
37 return {
38 run: async (argv, cwd) => {
39 try {
40 return await $.process.run(argv, { cwd, timeoutMs: 120000 })
41 } catch (error) {
42 return { exitCode: 127, stdout: '', stderr: describe(error) }
43 }
44 },
45 get: (key) => $.store.get(key),
46 set: (key, value) => $.store.set(key, value),
47 keys: () => $.store.keys(),
48 toast: (text, timeoutMs) => (timeoutMs === undefined ? $.ui.toast(text) : $.ui.toast(text, { timeoutMs })),
49 debug: (text) => {
50 try {
51 $.ui.log(text, { to: 'debug' })
52 } catch {
53 // the debug log is best effort
54 }
55 },
56 spawn: (spec) => $.agent.spawn(spec),
57 commandFailed: (tool) => recheckAfterFailure($, tool),
58 }
59}
60
61function kitOf(el: Omit<Kit, 'Input'> & Partial<Pick<Kit, 'Input'>>): Kit {
62 return { Box: el.Box, Text: el.Text, Button: el.Button, Markdown: el.Markdown, Input: el.Input ?? null }
63}
64
65// The band shows what blocks the mod; a toast pops up each missing requirement once per session, and again when an
66// action using its tool failed.
67async function showRequirements($: Engine, missing: Missing[], failedTool?: string): Promise<void> {
68 await update($, setup, () => missing)
69 const now = await $.clock.now()
70 let due: Missing[] = []
71 await update($, popups, (seen) => {
72 const shownAt = seen ?? {}
73 due = dueForPopup(missing, shownAt, now, failedTool)
74 return { ...shownAt, ...Object.fromEntries(due.map((m) => [m.id, now])) }
75 })
76 if (due.length > 0) {
77 $.ui.toast(popupText(due), { timeoutMs: POPUP_MS })
78 }
79}
80
81async function recheckAfterFailure($: Engine, tool: 'git' | 'gh'): Promise<void> {
82 const now = await $.clock.now()
83 if (now - lastRecheck < RECHECK_MS) {
84 return
85 }
86 lastRecheck = now
87 const missing = await checkRequirements(host($), sessionCheckout)
88 await showRequirements($, missing, tool)
89}
90
91async function setUp($: Engine): Promise<Missing[]> {
92 const io = host($)
93 const missing = await checkRequirements(io, sessionCheckout)
94 await showRequirements($, missing)
95 const blocked = blocking(missing)
96 if (blocked.length === 0) {
97 await registerRepo(io, sessionCheckout)
98 }
99 return blocked
100}
101
102// Polling goes ahead only when nothing blocks it.
103async function ready($: Engine): Promise<boolean> {
104 const blocked = await setUp($)
105 if (blocked.length > 0) {
106 await logError(host($), 'setup', blocked.map(describeMissing).join('; '))
107 }
108 return blocked.length === 0
109}
110
111
112// A press returns at once and the work runs after it, so the surface never waits on git, an agent spawn or the store.
113function inBackground($: Engine, action: string, work: () => Promise<unknown>): void {
114 void work().catch(async (error) => {
115 await logError(host($), action, error)
116 $.ui.toast(`${action} failed: ${describe(error)}`)
117 })
118}
119
120// Approve, dismiss and redo change the list before their slow work; the next refresh shows the real state.
121async function showStatus($: Engine, t: Tracked, status: Status | null): Promise<void> {
122 await update($, shown, (current) =>
123 (current ?? []).flatMap((item) => (item.key !== t.key ? [item] : status === null ? [] : [{ ...item, status }])),
124 )
125}
126
127async function refreshShown($: Engine): Promise<void> {
128 const io = host($)
129 const latest = await visible(io)
130 await update($, shown, () => latest)
131 const states = await branchStates(io)
132 await update($, branches, () => states)
133 const log = await recentErrors(io)
134 await update($, errors, () => log)
135}
136
137async function openPane($: Engine): Promise<void> {
138 await $.ui.open({ id: PANE, title: 'Docs updates' })
139}
140
141function paneActions($: Engine): PaneActions {
142 const io = host($)
143 const approveThem = async (items: Tracked[]) => {
144 for (const t of items) {
145 await showStatus($, t, 'applying')
146 }
147 for (const t of items) {
148 await approve(io, t)
149 }
150 await refreshShown($)
151 }
152 return {
153 approve: (t) => inBackground($, 'Approve', () => approveThem([t])),
154 approveAll: (items) => inBackground($, 'Approve all', () => approveThem(items)),
155 toggleDiff: (t) => inBackground($, 'Diff', () => update($, diff, (open) => (open?.key === t.key ? null : { key: t.key, text: t.diff ?? '' }))),
156 dismiss: (t) =>
157 inBackground($, 'Dismiss', async () => {
158 await showStatus($, t, null)
159 await dismiss(io, t)
160 await refreshShown($)
161 }),
162 redo: (t, note) =>
163 note.trim() !== '' &&
164 inBackground($, 'Redo', async () => {
165 await showStatus($, t, 'drafting')
166 await redo(io, t, note.trim())
167 await refreshShown($)
168 }),
169 openPr: (url) => inBackground($, 'Open', () => runLogged(io, 'open', ['open', url])),
170 clearErrors: () =>
171 inBackground($, 'Clear errors', async () => {
172 await clearErrors(io)
173 await update($, errors, () => [])
174 }),
175 }
176}
177
178function bandActions($: Engine, announced: string[]): BandActions {
179 const io = host($)
180 return {
181 recheck: () =>
182 inBackground($, 'Check', async () => {
183 const blocked = await setUp($)
184 if (blocked.length === 0) {
185 $.ui.toast('docs-watch is set up.')
186 }
187 }),
188 review: () => inBackground($, 'Review', () => openPane($)),
189 openPr: (url) => inBackground($, 'Open', () => runLogged(io, 'open', ['open', url])),
190 hide: () =>
191 inBackground($, 'Hide', async () => {
192 for (const repo of announced) {
193 await saveBranchState(io, repo, { ...(await branchState(io, repo)), announce: false })
194 }
195 await refreshShown($)
196 }),
197 retry: () =>
198 inBackground($, 'Retry', async () => {
199 await clearPushErrors(io)
200 await refreshShown($)
201 }),
202 }
203}
204
205async function redoCommand($: Engine, key: string, note: string): Promise<{ text: string }> {
206 const io = host($)
207 const target = await tracked(io, key)
208 if (!target || target.status !== 'suggested') {
209 return { text: `No suggestion waiting for ${key}.` }
210 }
211 await redo(io, target, note.trim())
212 await refreshShown($)
213 return { text: `Redrafting ${key}.` }
214}
215
216export const register: Register = (on) => {
217 on('session.start', async ($, e, next) => {
218 const io = host($)
219 await $.command.register({ name: 'docs-watch', description: 'Review documentation updates drafted after your PRs merged' })
220 sessionCheckout = await toplevel(io)
221 await setUp($)
222 await startPending(io)
223 await refreshShown($)
224 const poll = async () => {
225 await tick(io, () => ready($))
226 await refreshShown($)
227 }
228 $.clock.after(FIRST_TICK_MS, poll)
229 $.clock.every(TICK_MS, poll)
230 $.clock.every(RESOLVE_CHECK_MS, async () => {
231 await advanceQueues(io)
232 await refreshShown($)
233 })
234
235 return next(e)
236 })
237
238 on('turn.complete', async ($, e, next) => {
239 await startPending(host($))
240 await refreshShown($)
241
242 return next(e)
243 })
244
245 // `/docs-watch redo <owner/repo#number> <note>` redrafts one suggestion with a note; anything else opens the list.
246 on('command.run', { command: 'docs-watch' }, async ($, e) => {
247 const redoArgs = /^redo\s+(\S+)\s+([\s\S]+)$/.exec(e.args.trim())
248 if (redoArgs) {
249 return redoCommand($, redoArgs[1] ?? '', redoArgs[2] ?? '')
250 }
251 await startPending(host($))
252 await refreshShown($)
253 await openPane($)
254
255 return { text: 'Docs updates opened.' }
256 })
257
258 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
259 const current = (await read($, shown)) ?? []
260 const states = Object.entries((await read($, branches)) ?? {})
261 const model: BandModel = {
262 blocked: blocking((await read($, setup)) ?? []),
263 waiting: current.filter((t) => t.status === 'suggested').length,
264 announced: states.filter(([, b]) => b.announce && b.prUrl),
265 failed: states.filter(([, b]) => b.lastError && !b.pushing),
266 pushing: states.some(([, b]) => b.pushing),
267 }
268 if (e.props.hasSurvey || bandIsEmpty(model)) {
269 return next(e)
270 }
271 const announced = model.announced.map(([repo]) => repo)
272 return bandView(kitOf($.ui.resolve(e)), model, bandActions($, announced))
273 })
274
275 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
276 const model = {
277 current: (await read($, shown)) ?? [],
278 open: await read($, diff),
279 log: (await read($, errors)) ?? [],
280 missing: (await read($, setup)) ?? [],
281 }
282 return paneView(kitOf($.ui.resolve(e)), model, paneActions($))
283 })
284}
285hooks/band.tsx 54 lines1import type { RenderElement } from 'claude-code'
2
3import type { BranchState, Missing } from '../types'
4import type { Kit } from './host'
5import { describeMissing } from './requirements'
6
7export type BandModel = {
8 blocked: Missing[]
9 waiting: number
10 announced: [string, BranchState][]
11 failed: [string, BranchState][]
12 pushing: boolean
13}
14
15export type BandActions = {
16 recheck: () => void
17 review: () => void
18 openPr: (url: string) => void
19 hide: () => void
20 retry: () => void
21}
22
23export const bandIsEmpty = (m: BandModel) =>
24 m.blocked.length === 0 && m.waiting === 0 && m.announced.length === 0 && m.failed.length === 0 && !m.pushing
25
26export function bandView(kit: Kit, m: BandModel, act: BandActions): RenderElement {
27 const { Box, Button, Text } = kit
28 if (m.blocked.length > 0) {
29 return (
30 <Box>
31 <Text color="yellow">{`docs-watch: ${m.blocked.map(describeMissing).join('. ')}. Then press Check again. `}</Text>
32 <Button key="recheck" label="Check again" onPress={act.recheck} />
33 </Box>
34 )
35 }
36 const parts = [
37 m.waiting > 0 ? `${m.waiting} docs update(s) suggested` : '',
38 m.pushing ? 'Pushing docs updates…' : '',
39 m.announced.length > 0 ? 'Docs PR updated' : '',
40 m.failed[0] ? `Docs push failed: ${m.failed[0][1].lastError}` : '',
41 ]
42 return (
43 <Box>
44 <Text>{`${parts.filter(Boolean).join(' · ')} `}</Text>
45 {m.waiting > 0 && <Button key="review" label="Review" onPress={act.review} />}
46 {m.announced.map(([repo, b]) => (
47 <Button key={`open-${repo}`} label={m.announced.length > 1 ? `Open ${repo} PR` : 'Open PR'} onPress={() => act.openPr(b.prUrl ?? '')} />
48 ))}
49 {m.announced.length > 0 && <Button key="hide" label="Hide" onPress={act.hide} />}
50 {m.failed.length > 0 && <Button key="retry" label="Retry" onPress={act.retry} />}
51 </Box>
52 )
53}
54hooks/branch.ts 196 lines1import type { BranchState, Tracked } from '../types'
2import { DRAFT_TIMEOUT_MS, spawnFor } from './draft'
3import { BRANCH, defaultBase, ensureBranch, picking, rebuild, removeWorktree, replay } from './git'
4import type { Host } from './host'
5import { logError, notify, readResult, runLogged, stateDir } from './log'
6import { pushRunning, requestPush } from './push'
7import { allTracked, branchState, branchWorktree, checkoutOf, leaseIsMine, registeredRepos, save, saveBranchState } from './store'
8import { docsTitleFrom, lastLine, slug } from './text'
9
10export async function mergeResult(io: Host, t: Tracked): Promise<string> {
11 return `${await stateDir(io)}/results/${slug(t.key)}.merge.json`
12}
13
14// Commits the draft in its own worktree and queues it to land on the shared branch. A ref keeps the commit alive after
15// its worktree is removed, for later rebuilds. Landing is left to the session holding the poll lease (advanceQueues).
16export async function approve(io: Host, t: Tracked): Promise<void> {
17 const branch = t.checkout ? await ensureBranch(io, t.repo, t.checkout) : null
18 if (!branch || !t.work || !t.checkout) {
19 await logError(io, 'approve', 'the shared docs branch could not be created', t.key)
20 await save(io, { ...t, status: 'failed', error: 'the shared docs branch could not be created' })
21 return
22 }
23 await runLogged(io, 'approve', ['git', '-C', t.work, 'add', '-A'], undefined, t.key)
24 const body = `${t.summary ?? ''}\n\nAfter #${t.number}${t.url ? ` (${t.url})` : ''}.`
25 const commitArgs = ['git', '-C', t.work, 'commit', '-q', '-m', t.docsTitle ?? docsTitleFrom(t), '-m', body]
26 const committed = await runLogged(io, 'approve', commitArgs, undefined, t.key)
27 const head = await runLogged(io, 'approve', ['git', '-C', t.work, 'rev-parse', 'HEAD'], undefined, t.key)
28 if (committed.exitCode !== 0 || head.exitCode !== 0) {
29 await save(io, { ...t, status: 'failed', error: lastLine(committed) })
30 return
31 }
32 const commit = head.stdout.trim()
33 await runLogged(io, 'approve', ['git', '-C', t.checkout, 'update-ref', `refs/docs-watch/${slug(t.key)}`, commit], undefined, t.key)
34 await removeWorktree(io, t)
35 await save(io, { ...t, status: 'applying', commit, approvedAt: Date.now() })
36}
37
38// Lands approvals on the shared branch one at a time, oldest first, and pushes once none is left to land.
39async function applyNext(io: Host, repo: string): Promise<void> {
40 const checkout = await checkoutOf(io, repo)
41 const branch = checkout ? await ensureBranch(io, repo, checkout) : null
42 if (!branch || (await picking(io, branch))) {
43 return
44 }
45 let more = true
46 while (more) {
47 more = await landOne(io, repo, branch)
48 }
49}
50
51// A conflict goes to the docs-merge agent and the queue waits for it. True while there is more to do.
52async function landOne(io: Host, repo: string, branch: string): Promise<boolean> {
53 const mine = (await allTracked(io)).filter((t) => t.repo === repo)
54 if (mine.some((t) => t.status === 'resolving')) {
55 return false
56 }
57 const next = mine.filter((t) => t.status === 'applying').sort((a, b) => (a.approvedAt ?? 0) - (b.approvedAt ?? 0))[0]
58 if (!next) {
59 return mine.some((t) => t.status === 'approved') && (await requestPush(io, repo))
60 }
61 if (!next.commit) {
62 await logError(io, 'apply', 'its commit is missing', next.key)
63 await save(io, { ...next, status: 'failed', error: 'its commit is missing; clear it and it drafts again after the next merge' })
64 return true
65 }
66 if ((await replay(io, branch, next.commit)) === 'conflict') {
67 await startResolve(io, next)
68 return false
69 }
70 await save(io, { ...next, status: 'approved', landedAt: Date.now() })
71 return true
72}
73
74async function startResolve(io: Host, t: Tracked): Promise<void> {
75 await runLogged(io, 'resolve', ['rm', '-f', await mergeResult(io, t)], undefined, t.key)
76 const resolving: Tracked = { ...t, status: 'resolving', startedAt: Date.now() }
77 await save(io, resolving)
78 await startMerger(io, resolving)
79 await notify(io, `Resolving a docs conflict for ${t.key}…`)
80}
81
82export async function startMerger(io: Host, t: Tracked): Promise<void> {
83 const branch = await branchWorktree(io, t.repo)
84 const prompt = [
85 `Repository ${t.repo}. In this worktree (${branch}), branch ${BRANCH} has a cherry-pick in progress that conflicts:`,
86 `the documentation update after merged pull request #${t.number} ("${t.title ?? ''}"; ${t.summary ?? ''}) against updates`,
87 `already on the branch. Write your result file to ${await mergeResult(io, t)}.`,
88 ].join(' ')
89 await spawnFor(io, t, 'resolve', { prompt, subagentType: 'docs-watch:docs-merge', description: `Resolve docs conflict for ${t.key}`, cwd: branch })
90}
91
92async function checkResolve(io: Host, t: Tracked): Promise<void> {
93 const branch = await branchWorktree(io, t.repo)
94 const result = await readResult(io, await mergeResult(io, t), t.key)
95 if (!result && Date.now() - (t.startedAt ?? 0) <= DRAFT_TIMEOUT_MS) {
96 return
97 }
98 const resolved = result?.ok === true && !(await picking(io, branch))
99 await (resolved ? save(io, { ...t, status: 'approved', landedAt: Date.now() }) : failResolve(io, t, branch, result))
100}
101
102async function failResolve(io: Host, t: Tracked, branch: string, result: Record<string, unknown> | null): Promise<void> {
103 await runLogged(io, 'resolve', ['git', '-C', branch, 'cherry-pick', '--abort'], undefined, t.key)
104 const reason = result ? String(result.error ?? 'the conflict was not resolved') : 'the conflict was not resolved in time'
105 await logError(io, 'resolve', reason, t.key)
106 await save(io, { ...t, status: 'failed', error: reason })
107 await notify(io, `A docs conflict for ${t.key} could not be resolved: ${reason}`)
108}
109
110// Work on the machine-wide worktrees runs one pass at a time in this session; a pass asked for while one runs is
111// skipped, and the next timer tick picks up what it would have done.
112let running: Promise<void> | null = null
113
114export async function exclusive(work: () => Promise<void>): Promise<void> {
115 if (running) {
116 return
117 }
118 running = work().finally(() => {
119 running = null
120 })
121 await running
122}
123
124// Only the session holding the poll lease lands approvals on and pushes the machine-wide worktree; it runs this every
125// few seconds, so an approval or a resolved conflict ships soon after it happens.
126export async function advanceQueues(io: Host): Promise<void> {
127 if (await leaseIsMine(io)) {
128 await exclusive(() => advanceAll(io))
129 }
130}
131
132async function advanceAll(io: Host): Promise<void> {
133 const all = await allTracked(io)
134 for (const t of all.filter((t) => t.status === 'resolving' && !t.pendingStart)) {
135 await checkResolve(io, t)
136 }
137 for (const repo of await registeredRepos(io)) {
138 await advanceQueue(io, repo)
139 }
140}
141
142async function advanceQueue(io: Host, repo: string): Promise<void> {
143 const mine = (await allTracked(io)).filter((t) => t.repo === repo)
144 const state = await branchState(io, repo)
145 const unpushed = mine.some((t) => t.status === 'approved') && !state.lastError && !pushRunning(state)
146 if (mine.some((t) => t.status === 'applying') || unpushed) {
147 await applyNext(io, repo)
148 }
149}
150
151// Moves a repository's shared branch on with GitHub: its PR merged or closed, and main moving while nothing is pushed.
152export async function checkBranch(io: Host, repo: string): Promise<void> {
153 const state = await branchState(io, repo)
154 const checkout = await checkoutOf(io, repo)
155 const branch = await branchWorktree(io, repo)
156 if (pushRunning(state) || !checkout || (await io.run(['git', '-C', branch, 'rev-parse', '--git-dir'])).exitCode !== 0) {
157 return
158 }
159 await (state.prUrl ? followDocsPr(io, repo, state, branch, checkout) : followMain(io, repo, state, branch, checkout))
160}
161
162// Merged: its updates are done. Closed unmerged: they go back to approved and are pushed in a new PR.
163async function followDocsPr(io: Host, repo: string, state: BranchState, branch: string, checkout: string): Promise<void> {
164 const view = await runLogged(io, 'docs PR', ['gh', 'pr', 'view', state.prUrl ?? '', '--json', 'state', '-q', '.state'])
165 const prState = view.stdout.trim()
166 if (view.exitCode !== 0 || (prState !== 'MERGED' && prState !== 'CLOSED')) {
167 return
168 }
169 const merged = prState === 'MERGED'
170 for (const t of (await allTracked(io)).filter((t) => t.repo === repo && t.status === 'pushed')) {
171 await save(io, { ...t, status: merged ? 'merged' : 'approved' })
172 }
173 const baseSha = await rebuild(io, repo, branch, checkout)
174 await saveBranchState(io, repo, { baseSha: baseSha || state.baseSha })
175 await notify(io, merged ? `Docs PR merged for ${repo}.` : `Docs PR closed for ${repo}; its updates are back to approved.`)
176}
177
178async function followMain(io: Host, repo: string, state: BranchState, branch: string, checkout: string): Promise<void> {
179 await runLogged(io, 'docs branch', ['git', '-C', checkout, 'fetch', '-q', 'origin'])
180 const head = (await io.run(['git', '-C', checkout, 'rev-parse', await defaultBase(io, checkout)])).stdout.trim()
181 if (head && head !== state.baseSha) {
182 const baseSha = await rebuild(io, repo, branch, checkout)
183 await saveBranchState(io, repo, { ...state, pushing: false, baseSha: baseSha || state.baseSha })
184 }
185}
186
187// Retry clears the failed pushes' reasons, so the next pass of advanceQueues pushes them again.
188export async function clearPushErrors(io: Host): Promise<void> {
189 for (const repo of await registeredRepos(io)) {
190 const state = await branchState(io, repo)
191 if (state.lastError) {
192 await saveBranchState(io, repo, { ...state, lastError: undefined })
193 }
194 }
195}
196hooks/draft.ts 118 lines1import type { Tracked } from '../types'
2import { defaultBase, removeWorktree } from './git'
3import type { Host, SpawnSpec } from './host'
4import { logError, notify, readResult, runLogged, stateDir } from './log'
5import { checkoutOf, save } from './store'
6import { describe, docsTitleFrom, lastLine, slug } from './text'
7
8export const DRAFT_TIMEOUT_MS = 45 * 60 * 1000
9const DIFF_LIMIT = 60000
10
11export async function draftResult(io: Host, t: Tracked): Promise<string> {
12 return `${await stateDir(io)}/results/${slug(t.key)}.json`
13}
14
15// Subagents start only from a session. When the engine refuses or fails to start one (as it may from a timer), the item
16// waits with `pendingStart` and the next turn, `/docs-watch` or session start tries again; each refusal is logged.
17export async function spawnFor(io: Host, t: Tracked, where: string, spec: SpawnSpec): Promise<void> {
18 const started = await startAgent(io, spec)
19 if ('problem' in started) {
20 await logError(io, where, started.problem, t.key)
21 await save(io, { ...t, pendingStart: true, error: started.problem })
22 return
23 }
24 await save(io, { ...t, agentId: started.agentId, pendingStart: undefined, note: undefined, error: undefined, startedAt: Date.now() })
25}
26
27async function startAgent(io: Host, spec: SpawnSpec): Promise<{ agentId: string } | { problem: string }> {
28 try {
29 const started = await io.spawn(spec)
30 if ('deny' in started && started.deny) {
31 return { problem: `subagent refused: ${JSON.stringify(started.deny)}` }
32 }
33 return started.agentId ? { agentId: started.agentId } : { problem: 'subagent did not start (no agent id)' }
34 } catch (error) {
35 return { problem: `subagent failed to start: ${describe(error)}` }
36 }
37}
38
39export async function startDraft(io: Host, t: Tracked): Promise<void> {
40 const checkout = await checkoutOf(io, t.repo)
41 if (!checkout) {
42 await logError(io, 'draft', `no local checkout registered for ${t.repo}: open a Claude Code session in it`, t.key)
43 return
44 }
45 const dir = await stateDir(io)
46 const work = `${dir}/work/${slug(t.key)}`
47 await runLogged(io, 'draft', ['mkdir', '-p', `${dir}/work`, `${dir}/results`], undefined, t.key)
48 await runLogged(io, 'draft', ['git', '-C', checkout, 'fetch', '-q', 'origin'], undefined, t.key)
49 const base = await defaultBase(io, checkout)
50 await io.run(['git', '-C', checkout, 'worktree', 'remove', '--force', work])
51 const added = await runLogged(io, 'draft', ['git', '-C', checkout, 'worktree', 'add', '--detach', work, base], undefined, t.key)
52 if (added.exitCode !== 0) {
53 await save(io, { ...t, status: 'failed', error: lastLine(added) })
54 return
55 }
56 const drafting: Tracked = { ...t, status: 'drafting', checkout, work, base, startedAt: Date.now() }
57 await save(io, drafting)
58 await startWriter(io, drafting, '')
59}
60
61export async function startWriter(io: Host, t: Tracked, note: string): Promise<void> {
62 const lines = [
63 'Use the docs-watch:update-docs skill (call the Skill tool with it) and follow it exactly.',
64 `PR: ${t.repo}#${t.number} ("${t.title ?? ''}").`,
65 `Worktree: ${t.work}`,
66 `Result file: ${await draftResult(io, t)}`,
67 ]
68 if (note) {
69 lines.push(`Note: ${note}`)
70 }
71 const spec = { prompt: lines.join('\n'), subagentType: 'general-purpose', description: `Docs after ${t.key}`, cwd: t.work }
72 await spawnFor(io, { ...t, note: note || undefined }, 'draft', spec)
73}
74
75export async function checkDraft(io: Host, t: Tracked): Promise<void> {
76 const result = await readResult(io, await draftResult(io, t), t.key)
77 if (!result && (t.pendingStart || Date.now() - (t.startedAt ?? 0) <= DRAFT_TIMEOUT_MS)) {
78 return
79 }
80 if (!result) {
81 const where = t.agentId ? ` (agent ${t.agentId})` : ''
82 const error = `the draft wrote no result file within ${DRAFT_TIMEOUT_MS / 60000} minutes${where}`
83 await logError(io, 'draft', error, t.key)
84 await save(io, { ...t, status: 'failed', error })
85 return
86 }
87 const work = t.work ?? ''
88 await runLogged(io, 'draft', ['git', '-C', work, 'add', '-A'], undefined, t.key)
89 const changes = await runLogged(io, 'draft', ['git', '-C', work, 'diff', '--cached'], undefined, t.key)
90 const text = changes.stdout
91 if (text.trim() === '') {
92 await removeWorktree(io, t)
93 await save(io, { ...t, status: 'no-change' })
94 return
95 }
96 const summary = typeof result.summary === 'string' ? result.summary : 'Documentation update'
97 const docsTitle = typeof result.title === 'string' && result.title.startsWith('docs: ') ? result.title : docsTitleFrom(t)
98 await save(io, { ...t, status: 'suggested', summary, docsTitle, diff: text.slice(0, DIFF_LIMIT) })
99 await notify(io, `${t.key} merged: docs update suggested. Review with /docs-watch.`)
100}
101
102// Starts the draft again from a clean worktree, with the person's note.
103export async function redo(io: Host, t: Tracked, note: string): Promise<void> {
104 const work = t.work ?? ''
105 await runLogged(io, 'redo', ['git', '-C', work, 'reset', '-q', '--hard'], undefined, t.key)
106 await runLogged(io, 'redo', ['git', '-C', work, 'clean', '-fdq'], undefined, t.key)
107 await runLogged(io, 'redo', ['rm', '-f', await draftResult(io, t)], undefined, t.key)
108 const drafting: Tracked = { ...t, status: 'drafting', startedAt: Date.now(), diff: undefined, summary: undefined, docsTitle: undefined }
109 await save(io, drafting)
110 await startWriter(io, drafting, note)
111 io.toast(`Redrafting the docs for ${t.key}…`)
112}
113
114export async function dismiss(io: Host, t: Tracked): Promise<void> {
115 await removeWorktree(io, t)
116 await save(io, { ...t, status: 'dismissed' })
117}
118hooks/host.ts 30 lines1import type { AgentSpawnResult, BoxProps, ButtonProps, ElementConstructor, InputProps, MarkdownProps, TextProps } from 'claude-code'
2
3export type RunResult = { exitCode: number; stdout: string; stderr: string }
4
5export type SpawnSpec = { prompt: string; subagentType: string; description: string; cwd?: string }
6
7// The engine follows `$` only within the hooks module's own file, so register.tsx builds this from `$` and the other
8// files take it instead.
9export type Host = {
10 /** Never rejects: a command that cannot start resolves with exit code 127. */
11 run: (argv: string[], cwd?: string) => Promise<RunResult>
12 get: (key: string) => Promise<unknown>
13 set: (key: string, value: unknown) => Promise<void>
14 keys: () => Promise<string[]>
15 toast: (text: string, timeoutMs?: number) => void
16 debug: (text: string) => void
17 spawn: (spec: SpawnSpec) => Promise<AgentSpawnResult>
18 /** A git or gh command failed: re-check the requirements and pop up the missing ones that tool needs. */
19 commandFailed: (tool: 'git' | 'gh') => Promise<void>
20}
21
22// `Input` is null where the surface has none.
23export type Kit = {
24 Box: ElementConstructor<BoxProps>
25 Text: ElementConstructor<TextProps>
26 Button: ElementConstructor<ButtonProps>
27 Markdown: ElementConstructor<MarkdownProps>
28 Input: ElementConstructor<InputProps> | null
29}
30hooks/log.ts 79 lines1import type { LogEntry } from '../types'
2import type { Host } from './host'
3import { describe, lastLine, parseJson } from './text'
4
5const LOG_KEY = 'log'
6const LOG_LIMIT = 100
7const CLAUDE_APP = 'com.anthropic.claudefordesktop'
8
9// Every failure goes to the debug log and to the mod's own log, which the pane shows; logging itself never throws.
10export async function logError(io: Host, where: string, detail: unknown, key?: string): Promise<void> {
11 const message = describe(detail).slice(0, 2000)
12 io.debug(`docs-watch ${where}${key ? ` (${key})` : ''}: ${message}`)
13 try {
14 const log = ((await io.get(LOG_KEY)) as LogEntry[] | undefined) ?? []
15 const entry: LogEntry = { at: Date.now(), where, message, key }
16 await io.set(LOG_KEY, [...log.slice(-(LOG_LIMIT - 1)), entry])
17 } catch {
18 // the store failing leaves the debug log line as the only record
19 }
20}
21
22// A command whose failure matters: a non-zero exit is logged with the command and the last line of its output, and a
23// failed git or gh command re-checks the requirements.
24export async function runLogged(io: Host, where: string, argv: string[], cwd?: string, key?: string) {
25 const result = await io.run(argv, cwd)
26 if (result.exitCode === 0) {
27 return result
28 }
29 await logError(io, where, `\`${argv.join(' ').slice(0, 200)}\` exited ${result.exitCode}: ${lastLine(result)}`, key)
30 const tool = argv[0]
31 if (tool === 'git' || tool === 'gh') {
32 await io.commandFailed(tool)
33 }
34 return result
35}
36
37export async function recentErrors(io: Host): Promise<LogEntry[]> {
38 const log = ((await io.get(LOG_KEY)) as LogEntry[] | undefined) ?? []
39 return log.slice(-10).reverse()
40}
41
42export async function clearErrors(io: Host): Promise<void> {
43 await io.set(LOG_KEY, [])
44}
45
46export async function stateDir(io: Host): Promise<string> {
47 const env = await runLogged(io, 'home', ['printenv', 'HOME'])
48 return `${env.stdout.trim()}/.claude/docs-watch`
49}
50
51// macOS: with terminal-notifier a click brings Claude to the front, where the line above the prompt has the action, and
52// a newer notice replaces the last one. Without it a click does nothing, so the text names the step.
53export async function notify(io: Host, text: string): Promise<void> {
54 io.toast(text)
55 const os = await io.run(['uname'])
56 if (os.stdout.trim() !== 'Darwin') {
57 return
58 }
59 const notifier = await io.run(['terminal-notifier', '-title', 'Claude: docs', '-message', text, '-activate', CLAUDE_APP, '-group', 'docs-watch'])
60 if (notifier.exitCode === 0) {
61 return
62 }
63 const script = `display notification ${JSON.stringify(`${text} Open Claude and run /docs-watch.`)} with title "Claude: docs"`
64 await runLogged(io, 'notify', ['osascript', '-e', script])
65}
66
67// Null while the file does not exist yet; a file that is there but not JSON is logged.
68export async function readResult(io: Host, file: string, key: string): Promise<Record<string, unknown> | null> {
69 const out = await io.run(['cat', file])
70 if (out.exitCode !== 0) {
71 return null
72 }
73 const parsed = parseJson<Record<string, unknown>>(out.stdout)
74 if (parsed === null) {
75 await logError(io, 'result file', `${file} is not JSON: ${out.stdout.slice(0, 200)}`, key)
76 }
77 return parsed
78}
79hooks/pane.tsx 125 lines1import type { RenderElement } from 'claude-code'
2
3import type { LogEntry, Missing, Tracked } from '../types'
4import type { Kit } from './host'
5import { describeMissing } from './requirements'
6
7export type PaneModel = {
8 current: Tracked[]
9 open: { key: string; text: string } | null
10 log: LogEntry[]
11 missing: Missing[]
12}
13
14export type PaneActions = {
15 approve: (t: Tracked) => void
16 approveAll: (items: Tracked[]) => void
17 toggleDiff: (t: Tracked) => void
18 dismiss: (t: Tracked) => void
19 redo: (t: Tracked, note: string) => void
20 openPr: (url: string) => void
21 clearErrors: () => void
22}
23
24export function paneView(kit: Kit, m: PaneModel, act: PaneActions): RenderElement {
25 const { Box, Button, Text } = kit
26 const suggested = m.current.filter((t) => t.status === 'suggested')
27 return (
28 <Box flexDirection="column">
29 {m.missing.length > 0 && setupList(kit, m.missing)}
30 {m.current.length === 0 && <Text dimColor>No documentation updates waiting.</Text>}
31 {suggested.length > 1 && <Button key="approve-all" label={`Approve all ${suggested.length}`} onPress={() => act.approveAll(suggested)} />}
32 {m.current.map((t) => itemView(kit, t, m.open, act))}
33 {m.log.length > 0 && errorList(kit, m.log, act)}
34 </Box>
35 )
36}
37
38function setupList(kit: Kit, missing: Missing[]): RenderElement {
39 const { Box, Text } = kit
40 return (
41 <Box key="setup" flexDirection="column">
42 <Text bold color="yellow">Setup needed</Text>
43 {missing.map((m) => (
44 <Text key={`setup-${m.id}`} color="yellow">
45 {describeMissing(m)}
46 </Text>
47 ))}
48 </Box>
49 )
50}
51
52function errorList(kit: Kit, log: LogEntry[], act: PaneActions): RenderElement {
53 const { Box, Button, Text } = kit
54 return (
55 <Box key="errors" flexDirection="column">
56 <Text bold>Recent errors</Text>
57 {log.map((entry) => (
58 <Text key={`${entry.at}-${entry.where}`} color="red">
59 {`${new Date(entry.at).toLocaleString()} ${entry.where}${entry.key ? ` ${entry.key}` : ''}: ${entry.message}`}
60 </Text>
61 ))}
62 <Button key="clear-log" label="Clear errors" onPress={act.clearErrors} />
63 </Box>
64 )
65}
66
67function statusLine(kit: Kit, t: Tracked, act: PaneActions): RenderElement | null {
68 const { Box, Button, Text } = kit
69 if (t.status === 'drafting' && t.pendingStart) {
70 return <Text color="yellow">{`Waiting to start the draft: ${t.error ?? ''}`}</Text>
71 }
72 if (t.status === 'resolving') {
73 const waiting = `Waiting to start resolving a conflict: ${t.error ?? ''}`
74 return t.pendingStart ? <Text color="yellow">{waiting}</Text> : <Text>Resolving a conflict with another update…</Text>
75 }
76 if (t.status === 'pushed') {
77 return (
78 <Box>
79 <Text>{`In the docs PR: ${t.prUrl ?? ''} `}</Text>
80 <Button key={`open-${t.key}`} label="Open PR" onPress={() => act.openPr(t.prUrl ?? '')} />
81 </Box>
82 )
83 }
84 if (t.status === 'failed') {
85 return <Text color="red">{`Failed: ${t.error ?? ''}`}</Text>
86 }
87 const lines: Partial<Record<Tracked['status'], string>> = {
88 applying: 'Approved, adding to the docs branch…',
89 approved: 'Approved, pushing…',
90 }
91 const line = lines[t.status]
92 return line ? <Text>{line}</Text> : null
93}
94
95function itemView(kit: Kit, t: Tracked, open: PaneModel['open'], act: PaneActions): RenderElement {
96 const { Box, Button, Input, Markdown, Text } = kit
97 const isOpen = open !== null && open.key === t.key
98 return (
99 <Box key={t.key} flexDirection="column">
100 <Text bold>{t.docsTitle ?? `${t.key}: ${t.title ?? ''}`}</Text>
101 {t.docsTitle && <Text dimColor>{`After ${t.key}: ${t.title ?? ''}`}</Text>}
102 <Text dimColor>{t.summary ?? ''}</Text>
103 {statusLine(kit, t, act)}
104 {t.status === 'suggested' && (
105 <Box>
106 <Button key={`approve-${t.key}`} label="Approve" onPress={() => act.approve(t)} />
107 <Button key={`diff-${t.key}`} label={isOpen ? 'Hide diff' : 'Diff'} onPress={() => act.toggleDiff(t)} />
108 <Button key={`dismiss-${t.key}`} label="Dismiss" onPress={() => act.dismiss(t)} />
109 </Box>
110 )}
111 {t.status === 'suggested' && Input && (
112 <Input
113 key={`note-${t.key}`}
114 label="Redo with a note: "
115 placeholder="e.g. also update the README"
116 submitLabel="redo"
117 onSubmit={(value: string) => act.redo(t, value)}
118 />
119 )}
120 {t.status === 'failed' && <Button key={`clear-${t.key}`} label="Clear" onPress={() => act.dismiss(t)} />}
121 {isOpen && open && <Markdown text={`\`\`\`diff\n${open.text}\n\`\`\``} />}
122 </Box>
123 )
124}
125hooks/poll.ts 93 lines1import type { Status, Tracked } from '../types'
2import { checkBranch, exclusive, startMerger } from './branch'
3import { checkDraft, startDraft, startWriter } from './draft'
4import type { Host } from './host'
5import { logError, runLogged } from './log'
6import { allTracked, holdsLease, registeredRepos, save, tracked } from './store'
7import { onlyDocsTestsCi, parseJson } from './text'
8
9export async function tick(io: Host, ready: () => Promise<boolean>): Promise<void> {
10 try {
11 if (await ready()) {
12 await poll(io)
13 }
14 } catch (error) {
15 await logError(io, 'poll', error)
16 }
17}
18
19async function poll(io: Host): Promise<void> {
20 if (!(await holdsLease(io, Date.now()))) {
21 return
22 }
23 const repos = await registeredRepos(io)
24 for (const repo of repos) {
25 await trackOpen(io, repo)
26 }
27 for (const t of await allTracked(io)) {
28 await STEPS[t.status]?.(io, t)
29 }
30 await exclusive(async () => {
31 for (const repo of repos) {
32 await checkBranch(io, repo)
33 }
34 })
35}
36
37async function trackOpen(io: Host, repo: string): Promise<void> {
38 const open = await runLogged(io, 'list PRs', ['gh', 'pr', 'list', '--repo', repo, '--author', '@me', '--state', 'open', '--json', 'number,title,url'])
39 if (open.exitCode !== 0) {
40 return
41 }
42 const prs = parseJson<{ number: number; title: string; url: string }[]>(open.stdout)
43 if (prs === null) {
44 await logError(io, 'list PRs', `gh returned something that is not JSON for ${repo}: ${open.stdout.slice(0, 200)}`)
45 return
46 }
47 for (const pr of prs) {
48 const key = `${repo}#${pr.number}`
49 if (!(await tracked(io, key))) {
50 await save(io, { key, repo, number: pr.number, title: pr.title, url: pr.url, status: 'open' })
51 }
52 }
53}
54
55async function checkMerged(io: Host, t: Tracked): Promise<void> {
56 const view = await runLogged(io, 'check PR', ['gh', 'pr', 'view', String(t.number), '--repo', t.repo, '--json', 'state,files'], undefined, t.key)
57 if (view.exitCode !== 0) {
58 return
59 }
60 const info = parseJson<{ state: string; files: { path: string }[] }>(view.stdout)
61 if (info === null) {
62 await logError(io, 'check PR', `gh returned something that is not JSON: ${view.stdout.slice(0, 200)}`, t.key)
63 return
64 }
65 if (info.state === 'CLOSED') {
66 await save(io, { ...t, status: 'closed' })
67 return
68 }
69 if (info.state !== 'MERGED') {
70 return
71 }
72 const skip = onlyDocsTestsCi(info.files.map((f) => f.path))
73 await (skip ? save(io, { ...t, status: 'skipped' }) : startDraft(io, t))
74}
75
76const STEPS: Partial<Record<Status, (io: Host, t: Tracked) => Promise<void>>> = { open: checkMerged, drafting: checkDraft }
77
78const RESTARTS: Partial<Record<Status, (io: Host, t: Tracked) => Promise<void>>> = {
79 drafting: (io, t) => startWriter(io, t, t.note ?? ''),
80 resolving: startMerger,
81}
82
83// Starts the drafts and conflict resolutions that are waiting for a session to take them.
84export async function startPending(io: Host): Promise<void> {
85 try {
86 for (const t of (await allTracked(io)).filter((t) => t.pendingStart)) {
87 await RESTARTS[t.status]?.(io, t)
88 }
89 } catch (error) {
90 await logError(io, 'start pending', error)
91 }
92}
93hooks/requirements.ts 135 lines1import type { Missing } from '../types'
2import type { Host } from './host'
3
4// A requirement's popup comes back after a failed action at most this often, so one action's several failing
5// commands raise it once.
6const REPEAT_MS = 60 * 1000
7const CAN_PUSH = ['ADMIN', 'MAINTAIN', 'WRITE']
8
9// What the checks found; `null` where a check did not run because an earlier one failed.
10export type Facts = {
11 os: string
12 git: boolean
13 claude: boolean
14 gh: boolean
15 ghAuth: boolean | null
16 // this session's GitHub repository; `permission` is null when gh cannot reach it
17 repo: { name: string; permission: string | null } | null
18}
19
20// What this machine lacks: git, claude and gh on PATH, gh logged in to github.com, and the session's repository
21// reachable and pushable with that login. Only exit codes and the permission name are read, so no token is ever shown.
22export async function checkRequirements(io: Host, checkout: string | undefined): Promise<Missing[]> {
23 const os = (await io.run(['uname'])).stdout.trim()
24 const git = (await io.run(['git', '--version'])).exitCode === 0
25 const claude = (await io.run(['claude', '--version'])).exitCode === 0
26 const gh = (await io.run(['gh', '--version'])).exitCode === 0
27 const ghAuth = gh ? (await io.run(['gh', 'auth', 'status', '--hostname', 'github.com'])).exitCode === 0 : null
28 const repo = git && ghAuth && checkout ? await repoAccess(io, checkout) : null
29 return missingRequirements({ os, git, claude, gh, ghAuth, repo })
30}
31
32async function repoAccess(io: Host, checkout: string): Promise<Facts['repo']> {
33 const remote = await io.run(['git', '-C', checkout, 'remote', 'get-url', 'origin'])
34 const name = remote.exitCode === 0 ? githubRepoOf(remote.stdout) : null
35 if (!name) {
36 return null
37 }
38 const view = await io.run(['gh', 'repo', 'view', name, '--json', 'viewerPermission', '-q', '.viewerPermission'])
39 return { name, permission: view.exitCode === 0 ? view.stdout.trim() : null }
40}
41
42export const blocking = (missing: Missing[]) => missing.filter((m) => m.blocks)
43
44export function missingRequirements(facts: Facts): Missing[] {
45 const mac = facts.os === 'Darwin'
46 const missing: Missing[] = []
47 if (!facts.git) {
48 missing.push({
49 id: 'git',
50 tool: 'git',
51 blocks: true,
52 what: 'git is not installed or not on PATH',
53 fix: mac ? 'run `brew install git` (no Homebrew yet: `xcode-select --install`)' : 'install it from https://git-scm.com/downloads',
54 })
55 }
56 if (!facts.claude) {
57 missing.push({
58 id: 'claude',
59 tool: 'claude',
60 blocks: false,
61 what: 'the Claude Code CLI (`claude`) is not on PATH',
62 fix: 'install it with `curl -fsSL https://claude.ai/install.sh | bash`',
63 })
64 }
65 if (!facts.gh) {
66 missing.push({
67 id: 'gh',
68 tool: 'gh',
69 blocks: true,
70 what: 'the GitHub CLI (`gh`) is not installed or not on PATH',
71 fix: mac ? 'run `brew install gh`' : 'install it from https://cli.github.com',
72 })
73 }
74 if (facts.ghAuth === false) {
75 missing.push({
76 id: 'gh-auth',
77 tool: 'gh',
78 blocks: true,
79 what: 'gh is not logged in to github.com',
80 fix: 'run `gh auth login --hostname github.com`',
81 })
82 }
83 const repo = repoMissing(facts.repo)
84 if (repo) {
85 missing.push(repo)
86 }
87 return missing
88}
89
90function repoMissing(repo: Facts['repo']): Missing | null {
91 if (!repo) {
92 return null
93 }
94 if (repo.permission === null) {
95 return {
96 id: `repo:${repo.name}`,
97 tool: 'gh',
98 blocks: false,
99 what: `gh's github.com login cannot reach ${repo.name}`,
100 fix: 'run `gh auth refresh --hostname github.com --scopes repo`, or `gh auth login` as an account with access',
101 }
102 }
103 if (CAN_PUSH.includes(repo.permission)) {
104 return null
105 }
106 return {
107 id: `repo:${repo.name}`,
108 tool: 'gh',
109 blocks: false,
110 what: `gh's github.com login can read ${repo.name} but not push to it`,
111 fix: 'ask for write access, or run `gh auth login --hostname github.com` as an account that has it',
112 }
113}
114
115// `owner/name` from a GitHub remote URL (https or ssh); null for any other host.
116function githubRepoOf(remote: string): string | null {
117 const match = /github\.com[:/]([^/\s]+\/[^/\s]+?)(\.git)?\/?$/.exec(remote.trim())
118 return match?.[1] ?? null
119}
120
121export const describeMissing = (m: Missing) => `${m.what}: ${m.fix}`
122
123export const popupText = (due: Missing[]) => `docs-watch: ${due.map(describeMissing).join('. ')}.`
124
125// Each missing requirement pops up once per session, and again when an action using its tool failed.
126export function dueForPopup(missing: Missing[], shownAt: Record<string, number>, now: number, failedTool?: string): Missing[] {
127 return missing.filter((m) => {
128 const last = shownAt[m.id]
129 if (last === undefined) {
130 return true
131 }
132 return m.tool === failedTool && now - last >= REPEAT_MS
133 })
134}
135hooks/store.ts 95 lines1import type { BranchState, Status, Tracked } from '../types'
2import type { Host } from './host'
3import { runLogged, stateDir } from './log'
4import { slug } from './text'
5
6const LEASE_MS = 10 * 60 * 1000
7const SHOWN: readonly Status[] = ['suggested', 'applying', 'resolving', 'approved', 'pushed', 'failed']
8
9// This session's id for the poller lease: only one open session polls GitHub at a time.
10const me = Math.random().toString(36).slice(2)
11
12export async function allTracked(io: Host): Promise<Tracked[]> {
13 const keys = await io.keys()
14 const items: Tracked[] = []
15 for (const key of keys.filter((k) => k.startsWith('pr:'))) {
16 const value = (await io.get(key)) as Tracked | undefined
17 if (value) {
18 items.push(value)
19 }
20 }
21 return items
22}
23
24export async function tracked(io: Host, key: string): Promise<Tracked | undefined> {
25 return (await io.get(`pr:${key}`)) as Tracked | undefined
26}
27
28export async function save(io: Host, item: Tracked): Promise<void> {
29 await io.set(`pr:${item.key}`, item)
30}
31
32export async function visible(io: Host): Promise<Tracked[]> {
33 const all = await allTracked(io)
34 return all.filter((t) => SHOWN.includes(t.status) || (t.status === 'drafting' && t.pendingStart))
35}
36
37export async function holdsLease(io: Host, now: number): Promise<boolean> {
38 const lease = (await io.get('lease')) as { owner: string; until: number } | undefined
39 if (lease && lease.owner !== me && lease.until > now) {
40 return false
41 }
42 await io.set('lease', { owner: me, until: now + LEASE_MS })
43 return true
44}
45
46export async function leaseIsMine(io: Host): Promise<boolean> {
47 const lease = (await io.get('lease')) as { owner: string; until: number } | undefined
48 return lease?.owner === me && lease.until > Date.now()
49}
50
51export async function registeredRepos(io: Host): Promise<string[]> {
52 return (await io.keys()).filter((k) => k.startsWith('repo:')).map((k) => k.slice('repo:'.length))
53}
54
55export async function checkoutOf(io: Host, repo: string): Promise<string | undefined> {
56 return (await io.get(`repo:${repo}`)) as string | undefined
57}
58
59export async function branchState(io: Host, repo: string): Promise<BranchState> {
60 return ((await io.get(`branch:${repo}`)) as BranchState | undefined) ?? {}
61}
62
63export async function saveBranchState(io: Host, repo: string, state: BranchState): Promise<void> {
64 await io.set(`branch:${repo}`, state)
65}
66
67export async function branchStates(io: Host): Promise<Record<string, BranchState>> {
68 const states: Record<string, BranchState> = {}
69 for (const repo of await registeredRepos(io)) {
70 states[repo] = await branchState(io, repo)
71 }
72 return states
73}
74
75export async function branchWorktree(io: Host, repo: string): Promise<string> {
76 return `${await stateDir(io)}/branch/${slug(repo)}`
77}
78
79export async function toplevel(io: Host): Promise<string | undefined> {
80 const top = await io.run(['git', 'rev-parse', '--show-toplevel'])
81 return top.exitCode === 0 ? top.stdout.trim() : undefined
82}
83
84// The repository a session works in, so the poller knows where to make worktrees.
85export async function registerRepo(io: Host, checkout: string | undefined): Promise<void> {
86 if (!checkout) {
87 return
88 }
89 const name = await runLogged(io, 'register repo', ['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], checkout)
90 if (name.exitCode !== 0) {
91 return
92 }
93 await io.set(`repo:${name.stdout.trim()}`, checkout)
94}
95hooks/text.ts 44 lines1import type { Tracked } from '../types'
2
3// A PR that touched only these needs no documentation update.
4const NO_DOCS_NEEDED = /(^|\/)docs?\/|\.mdx?$|(^|\/)(test|tests|__tests__|spec)\/|(Spec|Test)\.(groovy|java|kt)$|\.(test|spec)\.[cm]?[jt]sx?$|^\.github\/|^\.claude\//
5const MAX_TITLE = 72
6
7export const onlyDocsTestsCi = (files: string[]) => files.length > 0 && files.every((file) => NO_DOCS_NEEDED.test(file))
8
9export const describe = (error: unknown) => (error instanceof Error ? `${error.name}: ${error.message}` : String(error))
10
11export const slug = (key: string) => key.replace(/[^A-Za-z0-9]+/g, '-')
12
13export const lastLine = (out: { stderr: string; stdout: string }) => (out.stderr.trim() || out.stdout.trim()).split('\n').pop() ?? ''
14
15export function parseJson<T>(text: string): T | null {
16 try {
17 return JSON.parse(text) as T
18 } catch {
19 return null
20 }
21}
22
23// For a draft whose result gave no title: the merged PR's title without its own type prefix.
24export function docsTitleFrom(t: Tracked): string {
25 const subject = (t.title ?? `pull request #${t.number}`).replace(/^\w+(\([^)]*\))?!?:\s*/, '')
26 return `docs: cover ${subject}`.slice(0, MAX_TITLE)
27}
28
29// The docs PR's title, from the branch's commit subjects (oldest first): what the docs cover, never only PR numbers.
30export function prTitle(subjects: string[]): string {
31 const topics = subjects.map((s) => s.replace(/^docs:\s*/, ''))
32 const joined = `docs: ${topics.join('; ')}`
33 if (topics.length === 1 || joined.length <= MAX_TITLE) {
34 return joined.slice(0, MAX_TITLE)
35 }
36 const more = ` (+${topics.length - 1} more)`
37 return `docs: ${topics[0]}`.slice(0, MAX_TITLE - more.length) + more
38}
39
40export function prBody(included: Tracked[]): string {
41 const lines = included.map((t) => `- #${t.number} ${t.title ?? ''} (${t.url ?? t.key}): ${t.summary ?? ''}`)
42 return ['Documentation updates after these merged pull requests:', '', ...lines].join('\n')
43}
44hooks/git.ts 61 lines1import type { Tracked } from '../types'
2import type { Host } from './host'
3import { runLogged, stateDir } from './log'
4import { allTracked, branchWorktree, save } from './store'
5
6// One branch per repository collects every approved update, so many merged PRs ship as one docs PR.
7export const BRANCH = 'docs/updates'
8
9export async function defaultBase(io: Host, checkout: string): Promise<string> {
10 const head = await io.run(['git', '-C', checkout, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
11 return head.exitCode === 0 ? head.stdout.trim() : 'origin/main'
12}
13
14export async function removeWorktree(io: Host, t: Tracked): Promise<void> {
15 if (t.checkout && t.work) {
16 await runLogged(io, 'remove worktree', ['git', '-C', t.checkout, 'worktree', 'remove', '--force', t.work], undefined, t.key)
17 }
18}
19
20// The worktree on the shared branch, created from the default branch the first time.
21export async function ensureBranch(io: Host, repo: string, checkout: string): Promise<string | null> {
22 const work = await branchWorktree(io, repo)
23 if ((await io.run(['git', '-C', work, 'rev-parse', '--git-dir'])).exitCode === 0) {
24 return work
25 }
26 await runLogged(io, 'docs branch', ['mkdir', '-p', `${await stateDir(io)}/branch`])
27 await runLogged(io, 'docs branch', ['git', '-C', checkout, 'worktree', 'prune'])
28 const hasBranch = await io.run(['git', '-C', checkout, 'rev-parse', '--verify', '--quiet', `refs/heads/${BRANCH}`])
29 const add = hasBranch.exitCode === 0
30 ? ['git', '-C', checkout, 'worktree', 'add', work, BRANCH]
31 : ['git', '-C', checkout, 'worktree', 'add', '-b', BRANCH, work, await defaultBase(io, checkout)]
32 const added = await runLogged(io, 'docs branch', add)
33 return added.exitCode === 0 ? work : null
34}
35
36// A conflicting cherry-pick is left in progress for the docs-merge agent to resolve.
37export async function replay(io: Host, branch: string, commit: string): Promise<'applied' | 'conflict'> {
38 const picked = await io.run(['git', '-C', branch, 'cherry-pick', '--allow-empty', commit])
39 return picked.exitCode === 0 ? 'applied' : 'conflict'
40}
41
42export async function picking(io: Host, branch: string): Promise<boolean> {
43 return (await io.run(['git', '-C', branch, 'rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'])).exitCode === 0
44}
45
46// Restarts the branch from the latest default branch and queues the approvals still to ship to land again, through
47// the same queue as a new approval, so a conflict with the new main is resolved the same way. Returns the base commit.
48export async function rebuild(io: Host, repo: string, branch: string, checkout: string): Promise<string> {
49 const dirty = (await io.run(['git', '-C', branch, 'status', '--porcelain'])).stdout.trim() !== ''
50 if (dirty || (await picking(io, branch))) {
51 return ''
52 }
53 await runLogged(io, 'rebuild', ['git', '-C', checkout, 'fetch', '-q', 'origin'])
54 const base = await defaultBase(io, checkout)
55 await runLogged(io, 'rebuild', ['git', '-C', branch, 'reset', '-q', '--hard', base])
56 for (const t of (await allTracked(io)).filter((t) => t.repo === repo && t.status === 'approved')) {
57 await save(io, { ...t, status: 'applying' })
58 }
59 return (await runLogged(io, 'rebuild', ['git', '-C', checkout, 'rev-parse', base])).stdout.trim()
60}
61