SLOPSHOPPER

docs-watch

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

newpanebandcommandtoastprocess
v0.3.0no licenseupdated 2026-10-13Elmanuel1/claude-mods/docs-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · docs-watch
│ ┃ Docs updates ✕ › fix the failing auth test and add an audit log call │ ┃ No documentation updates waiting. │ ⏺ 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 │ │ › /docs-watch │ ⎿ docs-watch: Docs updates opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Docs updates
No documentation updates waiting.
README

docs-watch

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.

Install

/plugin install docs-watch --marketplace Elmanuel1/claude-mods

Answer y to add the marketplace, then pick a scope.

Requirements

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:

MissingFix shown
gitbrew install git (macOS; without Homebrew, xcode-select --install), or https://git-scm.com/downloads
ghbrew install gh (macOS), or https://cli.github.com
gh not logged ingh auth login --hostname github.com
claude`curl -fsSL https://claude.ai/install.sh \bash`
repository not reachable or read-onlygh 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.

How it works

  1. Repositories. Each Claude Code session registers the GitHub repository it runs in. Nothing to configure.
  2. Watching. While any Claude Code session is open, one of them checks GitHub every 5 minutes (20 seconds after a session starts, then every 5 minutes) for your open pull requests in those repositories, and notices when one merges. Only one session polls at a time.
  3. Drafting. A merged pull request that changed more than docs, tests or CI gets a draft: the mod creates a worktree of the default branch under ~/.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.
  4. Review. A line above the prompt says "N docs update(s) suggested" in every open session, with a desktop notification on macOS. /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.
  5. Approve ships. Approve (or Approve all) commits the draft under the title the skill wrote (what the docs now cover) and queues it for one branch per repository, 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.
  6. Keeping up. The 5-minute check also follows the docs pull request: once it merges (squash or merge), its updates leave the list and the branch starts again from the default branch; if it is closed unmerged, its updates go back to approved and are pushed again. While nothing is pushed, the branch is rebuilt whenever the default branch moves, through the same queue, so a conflict with the new default branch is resolved the same way.
  7. Notifications. On macOS each step also posts a notification. With terminal-notifier installed (brew install terminal-notifier), a newer one replaces the last instead of stacking.
  8. Errors. Every failed 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.

Limits

  • It checks only while a Claude Code session is open; a merge while none is open is picked up at the next session start.
  • Drafting and resolving a conflict run as subagents in your session, on your plan. Pushing and opening the PR run git and gh only.
Source 14 files
hooks/register.tsx 285 lines
1import { 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}
285
hooks/band.tsx 54 lines
1import 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}
54
hooks/branch.ts 196 lines
1import 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}
196
hooks/draft.ts 118 lines
1import 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}
118
hooks/host.ts 30 lines
1import 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}
30
hooks/log.ts 79 lines
1import 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}
79
hooks/pane.tsx 125 lines
1import 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}
125
hooks/poll.ts 93 lines
1import 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}
93
hooks/requirements.ts 135 lines
1import 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}
135
hooks/store.ts 95 lines
1import 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}
95
hooks/text.ts 44 lines
1import 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}
44
hooks/git.ts 61 lines
1import 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